diff --git a/src/cdn-name-snapshot.mjs b/src/cdn-name-snapshot.mjs new file mode 100644 index 0000000..168de0f --- /dev/null +++ b/src/cdn-name-snapshot.mjs @@ -0,0 +1,105 @@ +// Keep a public, read-only RAW name-beacon snapshot on the CDN. +// +// Theseus warm-starts its name index from a raw snapshot — the beacon's +// history plus the verbose transactions — and rebuilds the index from that +// evidence itself (buildIndexFromSnapshot), so the operator is not trusted +// for the interpretation. The only published copy lived on the Sia S3 +// endpoint, which needs SigV4 credentials for every read: every Theseus +// launch got a 403 and the copy was never refreshed anyway (it was written +// by publish-name-mirror.mjs, by hand, last on 2026-08-31). +// +// This writes the same snapshot format to a plain static file served by the +// dl vhost — public, view-only, no credentials, no wallet. Each run fetches +// the beacon history over electrum and only the transactions the previous +// file doesn't already hold, so a run is one history call plus whatever is +// new. Runs on the VPS from a systemd timer (VPS/SilentMode/cdn-name-snapshot.*), +// beside cdn-name-index.mjs, which publishes the interpreted index. +// +// Two public, read-only copies, on two storage backends: +// 1. --out the dl vhost's disk (https://dl.silentmode.st/bns-name-snapshot.json) +// 2. --sia the Sia bucket, served by the gateway +// (https://navigate.st/bns/silentmode.bch/bns-name-snapshot.json) +// The Sia copy is uploaded whenever its last uploaded root (kept beside --out +// as .sia-root) differs from the current one, so a failed upload heals +// on the next run. Credentials come from the environment (AK / SK, as in +// /etc/s3d/sync.env) and an S3 endpoint (--s3-endpoint). +// +// node cdn-name-snapshot.mjs [--out /opt/silent-mode/dl/bns-name-snapshot.json] +// [--sia s3://bns/silentmode/bns-name-snapshot.json --s3-endpoint http://127.0.0.1:8500] +import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import WebSocket from "ws"; + +// The gateway runtime on the VPS is flat (/opt/bns-gateway/*.js); the repo +// keeps the libraries under lib/. +async function load(file) { + for (const p of [`./${file}`, `./lib/${file}`]) { + try { return await import(new URL(p, import.meta.url)); } + catch (e) { if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; } + } + throw new Error(`${file} not found next to this script or in lib/`); +} +const R = await load("resolver-web.js"); +const { buildNameSnapshot } = await load("snapshot-name.js"); + +const args = process.argv.slice(2); +const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 && args[i + 1] ? args[i + 1] : d; }; +const OUT = opt("--out", "/opt/silent-mode/dl/bns-name-snapshot.json"); +const SIA = opt("--sia", ""); +const S3_ENDPOINT = opt("--s3-endpoint", "http://127.0.0.1:8500"); +const MIN_NAMES = 10; // fewer is a broken answer, not the registry + +// Second copy: push the file at OUT to Sia unless that root is already there. +function mirrorToSia(root) { + if (!SIA) return; + const marker = OUT + ".sia-root"; + let done = ""; try { done = readFileSync(marker, "utf8").trim(); } catch {} + if (done === root) return; + const env = { ...process.env }; + if (env.AK && !env.AWS_ACCESS_KEY_ID) env.AWS_ACCESS_KEY_ID = env.AK; + if (env.SK && !env.AWS_SECRET_ACCESS_KEY) env.AWS_SECRET_ACCESS_KEY = env.SK; + const r = spawnSync("aws", ["--endpoint-url", S3_ENDPOINT, "s3", "cp", OUT, SIA, "--content-type", "application/json", "--only-show-errors"], { env, encoding: "utf8" }); + if (r.status !== 0) { console.error(`sia mirror failed (${r.status}): ${(r.stderr || r.error?.message || "").trim()}`); process.exitCode = 1; return; } + writeFileSync(marker, root); + console.log(`mirrored to ${SIA} (root ${root.slice(0, 12)})`); +} + +let prev = null; +try { if (existsSync(OUT)) prev = JSON.parse(readFileSync(OUT, "utf8")); } catch { prev = null; } +const prevTxs = (prev && prev.beacon === R.BEACON_SCRIPTHASH && prev.txs) || {}; + +const el = await R.connectElectrum({ WebSocket, directIP: true }); +let history, txs = {}, fetched = 0; +try { + history = await el.call("blockchain.scripthash.get_history", [R.BEACON_SCRIPTHASH]); + if (!Array.isArray(history)) throw new Error("electrum returned no history"); + for (const h of history) { + if (prevTxs[h.tx_hash]) { txs[h.tx_hash] = prevTxs[h.tx_hash]; continue; } + txs[h.tx_hash] = await el.call("blockchain.transaction.get", [h.tx_hash, true]); + fetched++; + } +} finally { try { el.close(); } catch {} } + +// A server answering with a shorter history than we already published is +// lagging (or lying); keep the published copy rather than go backwards. +if (prev && Array.isArray(prev.history) && history.length < prev.history.length) { + console.log(`electrum history shorter than published (${history.length} < ${prev.history.length}) — not writing`); + process.exit(0); +} +const heights = history.map((h) => Number(h.height) || 0).filter((h) => h > 0); +const snapshot = buildNameSnapshot({ + beacon: R.BEACON_SCRIPTHASH, + history: history.map((h) => ({ tx_hash: h.tx_hash, height: h.height })), + txs, + asOfHeight: heights.length ? Math.max(...heights) : null, +}); +const names = R.buildIndexFromSnapshot({ snapshot }).size; +if (names < MIN_NAMES) throw new Error(`snapshot resolves to only ${names} names — not writing`); +if (prev && prev.root === snapshot.root) { + console.log(`unchanged (${names} names, root ${snapshot.root.slice(0, 12)})`); +} else { + writeFileSync(OUT + ".tmp", JSON.stringify(snapshot)); + renameSync(OUT + ".tmp", OUT); + console.log(`wrote ${OUT}: ${history.length} beacon txs (${fetched} fetched), ${names} names, height ${snapshot.asOfHeight ?? "?"}, root ${snapshot.root.slice(0, 12)}`); +} +mirrorToSia(snapshot.root); diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index 7a50923..5bca35c 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -65,7 +65,13 @@ export const NETWORKS = { // Sia bucket for operator-provided hosting (`//`) and the // published snapshots; CDN copy and Nostr d-tags of the same lists. hostingBucket: "bns", - nameListUrl: "https://s3.silentmode.st:8600/bns/name-list.json", + // Raw beacon snapshot (history + txs; clients rebuild the index from it), + // public and read-only — refreshed every 10 min by cdn-name-snapshot.mjs, + // which keeps two copies on two storage backends: the dl vhost's disk and + // the Sia bucket behind the gateway. (The old bns/name-list.json on the + // Sia S3 endpoint needs SigV4 for every read.) + nameListUrl: "https://dl.silentmode.st/bns-name-snapshot.json", + nameListMirrorUrl: "https://navigate.st/bns/silentmode.bch/bns-name-snapshot.json", cdnIndexUrl: "https://dl.silentmode.st/bns-name-index.json", nostrTags: { names: "bns-name-list", tlds: "bns-tld-list" }, explorerTx: "https://chipnet.imaginary.cash/tx/",