The snapshot parse, index builds, electrum sync and snapshot refresh ran on the browser's main thread at launch. They now run in bns-indexer.js, a utilityProcess, in two phases: the local snapshot first (no network), then — once the first page has loaded — the published snapshot (one download) and the electrum poll. Main keeps a mirror of the name map for its synchronous lookups; tabs no longer wait for the index to restore. With no local index yet, a name under a known BCNR TLD gets one lookup of just that name on the gateway and opens; the full snapshot and the electrum check follow in the background, and every quick answer is compared with the verified index when it lands (a mismatch reloads the affected tabs). Plain web hosts are never sent to the gateway, and the extension-publisher check only accepts verified data. DESIGN-bns-indexer-service.md: Ariadne's Thread as the owner of the one shared indexer (scope x mode, launch at start, power, and a resolver that never depends on the indexer).
331 lines
16 KiB
JavaScript
331 lines
16 KiB
JavaScript
// BNS name indexer — runs in its own process (Electron utilityProcess),
|
|
// started by main.js, so the snapshot parsing, index builds and electrum
|
|
// traffic never compete with the browser's main thread.
|
|
//
|
|
// Boot is two-phase:
|
|
// 1. fast lane: load the local snapshot (user cache > bundled), build the
|
|
// index from it and publish it. No network. This is what the first tab
|
|
// needs, and it is all that runs while the browser is still starting.
|
|
// 2. full work, once main says "go" (its first page has loaded): electrum
|
|
// server discovery, the Sia snapshot refresh, and the 30 s delta poll.
|
|
// A lookup that misses the snapshot before then still gets a live
|
|
// delta poll on demand — only that, nothing else.
|
|
//
|
|
// Protocol (process.parentPort messages):
|
|
// main -> indexer { type: "init", resolverPath, userData, bundledSnapshot, torPort }
|
|
// { type: "go" } start phase 2
|
|
// { type: "tor", port } electrum via Tor (null = direct)
|
|
// { type: "stop-polling" }
|
|
// { id, type: "poll" | "rebuild" | "ready" } replies { type: "reply", id, ok }
|
|
// indexer -> main { type: "index", builtAt, names: [[key, entry]...], network }
|
|
// { type: "fresh", builtAt } poll succeeded, nothing changed
|
|
// { type: "reply", id, ok, error? }
|
|
//
|
|
// The index entries are the resolver's own (records, owner, txid, height, …)
|
|
// built from the raw beacon transactions — the trust model is unchanged.
|
|
"use strict";
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { pathToFileURL } = require("url");
|
|
const WebSocket = require("ws");
|
|
|
|
const port = process.parentPort;
|
|
const log = (...a) => console.log("[bns]", ...a);
|
|
|
|
// Same guard as main.js: ws turns "closed while still connecting" into an
|
|
// uncaught error event. Electrum servers that reset mid-handshake are routine.
|
|
{
|
|
const close = WebSocket.prototype.close;
|
|
WebSocket.prototype.close = function (...args) {
|
|
if (this.readyState === WebSocket.CONNECTING && this.listenerCount("error") === 0) this.once("error", () => {});
|
|
return close.apply(this, args);
|
|
};
|
|
}
|
|
// A flaky network must not take the indexer down; main restarts it if it
|
|
// exits, but the index would go cold for a moment.
|
|
process.on("uncaughtException", (err) => { try { console.error("[bns] uncaught:", (err && err.stack) || err); } catch {} });
|
|
process.on("unhandledRejection", (err) => { try { console.error("[bns] unhandled rejection:", (err && err.message) || err); } catch {} });
|
|
|
|
let cfg = null;
|
|
let R = null;
|
|
async function getResolver() {
|
|
if (!R) R = await import(pathToFileURL(cfg.resolverPath).href);
|
|
return R;
|
|
}
|
|
|
|
// ---- Tor ------------------------------------------------------------------
|
|
let torAgent = null;
|
|
async function setTor(torPort) {
|
|
if (!torPort) { torAgent = null; return; }
|
|
const { SocksProxyAgent } = await import("socks-proxy-agent");
|
|
torAgent = new SocksProxyAgent(`socks5h://127.0.0.1:${torPort}`);
|
|
}
|
|
class TorWebSocket extends WebSocket { constructor(url, opts) { super(url, { agent: torAgent, ...opts }); } }
|
|
const currentWS = () => (torAgent ? TorWebSocket : WebSocket);
|
|
|
|
// ---- electrum server pool: hardcoded seed + on-chain discovery, persisted ----
|
|
// Bootstrap from the baked-in seed (with pinned IPs), then refresh from the
|
|
// on-chain ELECTRUM_LIST_NAME record so the pool can be rotated without a new
|
|
// build. The last discovered list is cached to disk and tried first next launch.
|
|
let electrumPool = null;
|
|
let lastElectrumRefresh = 0;
|
|
// Per-network cache files. Chipnet keeps the historical names so existing
|
|
// profiles are not invalidated; any other network gets its own files.
|
|
let networkId = "chipnet";
|
|
const netSuffix = () => (networkId === "chipnet" ? "" : `.${networkId}`);
|
|
const electrumFile = () => path.join(cfg.userData, `electrum-servers${netSuffix()}.json`);
|
|
const snapshotUserPath = () => path.join(cfg.userData, `bns-name-snapshot${netSuffix()}.json`);
|
|
const serverKey = (s) => (typeof s === "string" ? s : s && s.url);
|
|
function mergeServers(preferred, rest) {
|
|
const seen = new Set(), out = [];
|
|
for (const s of [...(preferred || []), ...(rest || [])]) {
|
|
const k = serverKey(s);
|
|
if (k && !seen.has(k)) { seen.add(k); out.push(s); }
|
|
}
|
|
return out;
|
|
}
|
|
async function initElectrumPool() {
|
|
const r = await getResolver();
|
|
const seed = r.ELECTRUM || r.CHIPNET_ELECTRUM;
|
|
let saved = [];
|
|
try { if (fs.existsSync(electrumFile())) saved = JSON.parse(fs.readFileSync(electrumFile(), "utf8")); } catch {}
|
|
electrumPool = mergeServers(saved, seed); // discovered first, seed always kept
|
|
}
|
|
async function refreshElectrumPool() {
|
|
try {
|
|
const { fetchElectrumServers } = await getResolver();
|
|
const found = await fetchElectrumServers({ WebSocket: currentWS(), directIP: true, electrum: electrumPool });
|
|
if (found && found.length) {
|
|
electrumPool = mergeServers(found, electrumPool);
|
|
try { fs.writeFileSync(electrumFile(), JSON.stringify(found, null, 2)); } catch {}
|
|
}
|
|
} catch { /* list unpublished or unreachable — keep the current pool */ }
|
|
}
|
|
function maybeRefreshElectrum() {
|
|
if (!started) return; // phase 2 only
|
|
if (Date.now() - lastElectrumRefresh < 30 * 60 * 1000) return;
|
|
lastElectrumRefresh = Date.now();
|
|
refreshElectrumPool();
|
|
}
|
|
|
|
// ---- index state + publishing ---------------------------------------------
|
|
let index = null; // Map name -> entry
|
|
let currentSnapshotState = null; // raw { beacon, history, txs, … } behind `index`
|
|
let lastSig = "";
|
|
// Cheap fingerprint of the snapshot: an index rebuilt from the same history
|
|
// at the same heights is the same index, so main isn't sent a copy.
|
|
const sigOf = (snap) => `${snap.history.length}:${snap.history.reduce((a, h) => a + (Number(h.height) || 0), 0)}`;
|
|
function publish(idx, snap, reason) {
|
|
index = idx;
|
|
const sig = snap ? sigOf(snap) : `full:${Date.now()}`;
|
|
const builtAt = reason === "snapshot" ? 0 : Date.now(); // a snapshot is a floor, not a ceiling
|
|
if (sig === lastSig) { port.postMessage({ type: "fresh", builtAt }); return; }
|
|
lastSig = sig;
|
|
port.postMessage({ type: "index", builtAt, names: [...idx], network: networkId, reason });
|
|
}
|
|
|
|
function readSnapshotFrom(p) {
|
|
try {
|
|
if (!fs.existsSync(p)) return null;
|
|
const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
if (!parsed || !Array.isArray(parsed.history)) return null;
|
|
return parsed;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// Phase 1: the local snapshot, nothing else.
|
|
async function warmFromSnapshot() {
|
|
const r = await getResolver();
|
|
networkId = r.NETWORK?.id || "chipnet";
|
|
if (!r.buildIndexFromSnapshot) return false; // an older resolver-web.js
|
|
const snap = readSnapshotFrom(snapshotUserPath()) || readSnapshotFrom(cfg.bundledSnapshot);
|
|
if (!snap) return false;
|
|
try {
|
|
const t0 = Date.now();
|
|
const idx = r.buildIndexFromSnapshot({ snapshot: snap });
|
|
currentSnapshotState = snap;
|
|
publish(idx, snap, "snapshot");
|
|
log(`warm-started from snapshot: ${idx.size} names in ${Date.now() - t0} ms @ height=${snap.asOfHeight ?? "?"} root=${snap.root ?? "?"}`);
|
|
return true;
|
|
} catch (e) {
|
|
log("snapshot warm-start failed:", e.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---- continuous background delta refresh ----------------------------------
|
|
// One electrum connection per poll: fetch the beacon's history (one call),
|
|
// fetch only the tx bodies not already held, rebuild locally, persist.
|
|
const POLL_INTERVAL_MS = 30_000;
|
|
// Neither the electrum connect nor its WebSocket handshake has a timeout of
|
|
// its own; a server that accepts TCP and then stalls would wedge the loop.
|
|
const POLL_DEADLINE_MS = 45_000;
|
|
let pollInFlight = null, pollTimer = null;
|
|
async function pollAndMerge() {
|
|
if (pollInFlight) return pollInFlight;
|
|
let conn = null, deadline;
|
|
const work = (async () => {
|
|
const r = await getResolver();
|
|
if (!r.connectElectrum || !r.BEACON_SCRIPTHASH || !r.buildIndexFromSnapshot) return rebuild();
|
|
if (!electrumPool) await initElectrumPool();
|
|
// Base state: memory > user cache > bundled > empty. The "empty" branch
|
|
// turns a first start without any snapshot into a full fetch.
|
|
const snap = currentSnapshotState
|
|
|| readSnapshotFrom(snapshotUserPath())
|
|
|| readSnapshotFrom(cfg.bundledSnapshot)
|
|
|| { beacon: r.BEACON_SCRIPTHASH, history: [], txs: {} };
|
|
const el = conn = await r.connectElectrum({ electrum: electrumPool, WebSocket: currentWS(), directIP: true });
|
|
try {
|
|
const freshHistory = await el.call("blockchain.scripthash.get_history", [r.BEACON_SCRIPTHASH]);
|
|
const known = new Set(snap.history.map((h) => h.tx_hash));
|
|
// Merge fresh into snapshot history (dedup by tx_hash, keep fresh height —
|
|
// an event that was mempool at snapshot time now has a real height).
|
|
const merged = new Map(snap.history.map((h) => [h.tx_hash, h]));
|
|
const txs = { ...(snap.txs || {}) };
|
|
let added = 0;
|
|
for (const h of freshHistory) {
|
|
if (!known.has(h.tx_hash)) {
|
|
try { txs[h.tx_hash] = await el.call("blockchain.transaction.get", [h.tx_hash, true]); added++; }
|
|
catch { /* unreadable — the reduction rules ignore missing txs */ }
|
|
}
|
|
merged.set(h.tx_hash, { tx_hash: h.tx_hash, height: h.height });
|
|
}
|
|
const history = [...merged.values()];
|
|
currentSnapshotState = { ...snap, beacon: r.BEACON_SCRIPTHASH, history, txs };
|
|
const idx = r.buildIndexFromSnapshot({ snapshot: currentSnapshotState });
|
|
publish(idx, currentSnapshotState, "poll");
|
|
try {
|
|
fs.mkdirSync(path.dirname(snapshotUserPath()), { recursive: true });
|
|
fs.writeFileSync(snapshotUserPath(), JSON.stringify(currentSnapshotState));
|
|
} catch { /* readonly userdata / disk full — skip */ }
|
|
if (added > 0) log(`delta-refresh: +${added} new tx${added === 1 ? "" : "s"} (total ${history.length}, index has ${idx.size} names)`);
|
|
return true;
|
|
} finally { try { el.close(); } catch {} }
|
|
})().catch(() => false);
|
|
const timeout = new Promise((resolve) => {
|
|
deadline = setTimeout(() => { try { conn?.close(); } catch {} resolve(false); }, POLL_DEADLINE_MS);
|
|
});
|
|
const p = Promise.race([work, timeout]).finally(() => {
|
|
clearTimeout(deadline);
|
|
if (pollInFlight === p) pollInFlight = null;
|
|
maybeRefreshElectrum();
|
|
});
|
|
pollInFlight = p;
|
|
return p;
|
|
}
|
|
|
|
// Full walk over electrum — only for a resolver without the delta primitives,
|
|
// or an explicit rebuild request.
|
|
let rebuilding = null;
|
|
function rebuild() {
|
|
if (rebuilding) return rebuilding;
|
|
rebuilding = (async () => {
|
|
const r = await getResolver();
|
|
if (!electrumPool) await initElectrumPool();
|
|
const idx = await r.buildIndex({ WebSocket: currentWS(), directIP: true, electrum: electrumPool });
|
|
publish(idx, null, "rebuild");
|
|
return true;
|
|
})().catch(() => false).finally(() => { rebuilding = null; });
|
|
return rebuilding;
|
|
}
|
|
|
|
// The published raw snapshot (public, refreshed every 10 min on the VPS):
|
|
// one download that can carry a long-closed browser across days of beacon
|
|
// events, so the electrum poll after it only fetches what is newer still.
|
|
// Merged in now — not saved for the next start — and only ever adds: the
|
|
// index is rebuilt here from the transactions, as with any other source.
|
|
async function refreshFromPublishedSnapshot() {
|
|
try {
|
|
const r = await getResolver();
|
|
// Two public copies (dl vhost, Sia via the gateway): first one that answers.
|
|
const urls = [r.NETWORK?.nameListUrl, r.NETWORK?.nameListMirrorUrl].filter(Boolean);
|
|
let pub = null;
|
|
for (const url of urls) {
|
|
try {
|
|
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(15000) });
|
|
if (!res.ok) { log(`published snapshot ${new URL(url).host}: HTTP ${res.status}`); continue; }
|
|
pub = await res.json();
|
|
break;
|
|
} catch (e) { log(`published snapshot ${new URL(url).host}: ${e?.cause?.code || e?.message || e}`); }
|
|
}
|
|
if (!pub) return;
|
|
if (!pub || !Array.isArray(pub.history) || !pub.txs || (r.BEACON_SCRIPTHASH && pub.beacon !== r.BEACON_SCRIPTHASH)) return;
|
|
const base = currentSnapshotState || { beacon: r.BEACON_SCRIPTHASH, history: [], txs: {} };
|
|
const merged = new Map(base.history.map((h) => [h.tx_hash, h]));
|
|
const txs = { ...(base.txs || {}) };
|
|
let added = 0;
|
|
for (const h of pub.history) {
|
|
const mine = merged.get(h.tx_hash);
|
|
if (!pub.txs[h.tx_hash] && !txs[h.tx_hash]) continue; // no evidence, skip
|
|
if (!txs[h.tx_hash]) { txs[h.tx_hash] = pub.txs[h.tx_hash]; added++; }
|
|
// Keep a confirmed height over a mempool (0 / negative) one.
|
|
if (!mine || (!(Number(mine.height) > 0) && Number(h.height) > 0)) merged.set(h.tx_hash, { tx_hash: h.tx_hash, height: h.height });
|
|
}
|
|
const next = { ...base, beacon: base.beacon || pub.beacon, history: [...merged.values()], txs };
|
|
if (sigOf(next) === sigOf(base)) return;
|
|
currentSnapshotState = next;
|
|
publish(r.buildIndexFromSnapshot({ snapshot: next }), next, "published");
|
|
try {
|
|
fs.mkdirSync(path.dirname(snapshotUserPath()), { recursive: true });
|
|
fs.writeFileSync(snapshotUserPath(), JSON.stringify(next));
|
|
} catch {}
|
|
log(`published snapshot merged: +${added} tx${added === 1 ? "" : "s"} (total ${next.history.length}, ${index.size} names)`);
|
|
} catch (e) { log("published snapshot unavailable:", e?.message || e); }
|
|
}
|
|
|
|
// ---- phases -----------------------------------------------------------------
|
|
let warmed = null; // promise: phase 1 finished (index may still be null)
|
|
let started = false; // phase 2 running
|
|
function startFullWork() {
|
|
if (started) return;
|
|
started = true;
|
|
(async () => {
|
|
await warmed;
|
|
if (!electrumPool) await initElectrumPool();
|
|
// Fastest catch-up first: one download of the published snapshot, then
|
|
// the electrum delta for whatever is newer than that.
|
|
await refreshFromPublishedSnapshot();
|
|
lastElectrumRefresh = Date.now();
|
|
refreshElectrumPool();
|
|
// No snapshot at all (first start of a build without one): the poll's
|
|
// "empty" base state does the full fetch.
|
|
pollAndMerge();
|
|
pollTimer = setInterval(() => pollAndMerge(), POLL_INTERVAL_MS);
|
|
})().catch((e) => log("start failed:", e?.message));
|
|
}
|
|
|
|
// "ready": resolves once there is any index — the local snapshot, or (none
|
|
// on disk) the published one, one download; the live poll only if that
|
|
// failed too, since a first electrum walk fetches every beacon transaction.
|
|
let firstFetch = null;
|
|
async function whenReady() {
|
|
await warmed;
|
|
if (index) return true;
|
|
firstFetch = firstFetch || refreshFromPublishedSnapshot().finally(() => { firstFetch = null; });
|
|
await firstFetch;
|
|
if (index) return true;
|
|
return pollAndMerge();
|
|
}
|
|
|
|
port.on("message", async (e) => {
|
|
const m = e.data || {};
|
|
const reply = (ok, error) => { if (m.id != null) port.postMessage({ type: "reply", id: m.id, ok: !!ok, error }); };
|
|
try {
|
|
switch (m.type) {
|
|
case "init":
|
|
cfg = m;
|
|
await setTor(m.torPort);
|
|
warmed = warmFromSnapshot();
|
|
break;
|
|
case "go": startFullWork(); break;
|
|
case "tor": await setTor(m.port); break;
|
|
case "stop-polling": if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } break;
|
|
case "ready": reply(await whenReady()); break;
|
|
// An on-demand poll is allowed before phase 2: it is the "this tab's
|
|
// name isn't in the snapshot" case, and costs one electrum round trip.
|
|
case "poll": await warmed; reply(await pollAndMerge()); break;
|
|
case "rebuild": await warmed; reply(await rebuild()); break;
|
|
default: reply(false, "unknown request");
|
|
}
|
|
} catch (err) { reply(false, err?.message || String(err)); }
|
|
});
|