332 lines
16 KiB
JavaScript
332 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)); }
|
||
|
|
});
|