BNS: Sia+Nostr snapshot mirror, Theseus warm-start, VPS playbook, electrum.bch
Four independent pieces of one story — make cold-start .bch resolution fast, survive individual electrum outages, and turn adding a Silent Mode BNS server into a checklist run. * Argus/src/lib/snapshot-name.js — pure, interpretation-free snapshot format for the name beacon (raw history + verbose tx cache + deterministic root). * Argus/src/publish-name-mirror.mjs — mirror the snapshot to Sia S3 (floating + content-addressed) and Nostr NIP-33 (kind 30078, d-tag bns-name-list). Same pattern as publish-tld-mirror.mjs. * Argus/src/snapshot-name-to-file.mjs — dump the snapshot to a file for bundling. Writes to TheseusNavigator/snapshots/bns-name-snapshot.json. * Argus/src/lib/resolver-web.js — new export buildIndexFromSnapshot() runs the same REG/UPD reduction as buildIndex() but from a pre-fetched snapshot, no electrum required. Warm-start path in Theseus depends on this. * TheseusNavigator/main.js — warmFromSnapshot() and refreshSnapshotFromSia() wired into app.whenReady(). First .bch navigation returns from a warm index instead of waiting on a full electrum walk. * TheseusNavigator/package.json — bundles the starter snapshot as an extraResource so packaged builds ship with a floor. * TheseusNavigator/snapshots/bns-name-snapshot.json — first starter snapshot (70 beacon txs, root 8cc859aa…). * VPS/BNS-SERVER-PLAYBOOK.md — turn-key checklist for spinning up a second or third Silent Mode BNS server, distilled from what actually worked on the silentmode box (BCHN 29 [chip]-not-[chipnet], multi-beacon indexer, wss:// nginx, on-chain publish via electrum.bch, common gotchas table). * Argus/src/register-electrum-list.mjs — publish the current chipnet server pool as `el` on electrum.bch. Fits inside the 200-byte OP_RETURN cap by sizing the record (drops fallback IP pins in favour of URL-only tokens until they fit). NAME was chosen deliberately: two-part `electrum.bch` normalises to the bare distinct name `electrum`, whereas the earlier candidate `electrum.silentmode.bch` collapsed to silentmode.bch and would have inherited/overwritten that name's records. * Argus/src/revert-silentmode-bch.mjs — one-off recovery from the earlier register-electrum-list revision that hit exactly that subdomain-collapse bug (broadcast an UPD to silentmode.bch and clobbered its s3 record). Records recovered from the local snapshot cache; safe to leave in the tree as documentation of what happened.
This commit is contained in:
parent
9068eff9c7
commit
f29b82a50f
6 changed files with 473 additions and 1 deletions
|
|
@ -52,7 +52,15 @@ export const CHIPNET_ELECTRUM = [
|
|||
// A well-known BCNR name whose record carries the current electrum server pool.
|
||||
// Resolved AFTER bootstrap (you need a server to read it) to refresh the list
|
||||
// without shipping a new build — the decentralized, ICANN-free source of truth.
|
||||
export const ELECTRUM_LIST_NAME = "electrum.silentmode.bch";
|
||||
//
|
||||
// Choice of name is load-bearing. Under BNS's canonical-key rule, a two-part
|
||||
// name of the form `<label>.<tld>` is a distinct name, but a three-part name
|
||||
// like `electrum.silentmode.bch` collapses to its parent (`silentmode.bch`) —
|
||||
// it would inherit and OVERWRITE silentmode.bch's records on any UPD. So we
|
||||
// keep the discovery name at label depth under a real TLD: `electrum.bch`
|
||||
// normalises to bare `electrum` in the index, which does not conflict with
|
||||
// any existing name.
|
||||
export const ELECTRUM_LIST_NAME = "electrum.bch";
|
||||
|
||||
const hexToBytes = (h) => {
|
||||
const a = new Uint8Array(h.length / 2);
|
||||
|
|
@ -376,6 +384,59 @@ export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, c
|
|||
}
|
||||
}
|
||||
|
||||
// Build the index from a pre-fetched snapshot ({ history, txs }) — the shape
|
||||
// publish-name-mirror.mjs writes to Sia and Theseus caches on disk. Pure
|
||||
// function: no electrum, no fetch, no I/O. Runs the SAME reduction (sort,
|
||||
// dep-order, first-REG-wins, UPD-with-category-proof) so any consumer that
|
||||
// walks `snapshot.txs` through this converges on the same names Map as the
|
||||
// live buildIndex would from the same underlying chain state.
|
||||
//
|
||||
// Signature deliberately mirrors buildIndex so it's a drop-in for callers
|
||||
// that already had one (Theseus's ensureIndex warm-start path).
|
||||
export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap = null } = {}) {
|
||||
if (!snapshot || !Array.isArray(snapshot.history)) {
|
||||
throw new Error("buildIndexFromSnapshot: snapshot with `history` array required");
|
||||
}
|
||||
if (enforceTld && !tldMap) {
|
||||
// Snapshots don't embed the TLD map — the caller would have to load a TLD
|
||||
// snapshot separately and pass it in. Off by default to keep the fast-path
|
||||
// free of surprise dependencies.
|
||||
throw new Error("buildIndexFromSnapshot: enforceTld=true requires an explicit tldMap");
|
||||
}
|
||||
const txMap = snapshot.txs instanceof Map ? snapshot.txs : new Map(Object.entries(snapshot.txs || {}));
|
||||
const history = [...snapshot.history].sort(byHeightThenTxid);
|
||||
const names = new Map();
|
||||
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txMap.has(h.tx_hash)), txMap)) {
|
||||
const tx = txMap.get(tx_hash);
|
||||
const vouts = tx.vout || [];
|
||||
let payload = null;
|
||||
for (const v of vouts) payload = parseOpReturnHex(v.scriptPubKey?.hex) ?? payload;
|
||||
if (!payload) continue;
|
||||
const rawName = payload.n;
|
||||
if (!RAWNAME_RE.test(rawName)) continue;
|
||||
const key = canonicalKey(rawName);
|
||||
if (payload.o === "REG") {
|
||||
if (names.has(key)) continue;
|
||||
if (enforceTld && !tldAdmits(tldOfKey(key), height, tldMap)) continue;
|
||||
const commitHex = strToHex(rawName);
|
||||
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
|
||||
if (!mint) continue;
|
||||
names.set(key, {
|
||||
name: key, category: mint.tokenData.category, records: payload.r ?? {},
|
||||
txid: tx_hash, height, updatedTxid: null,
|
||||
});
|
||||
} else if (payload.o === "UPD") {
|
||||
const entry = names.get(key);
|
||||
if (!entry) continue;
|
||||
const proves = vouts.some((v) => v.tokenData && v.tokenData.category === entry.category);
|
||||
if (!proves) continue;
|
||||
entry.records = payload.r ?? {};
|
||||
entry.updatedTxid = tx_hash;
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export async function resolveName(input, opts = {}) {
|
||||
const name = normalizeName(input);
|
||||
const index = await buildIndex(opts);
|
||||
|
|
|
|||
96
src/lib/snapshot-name.js
Normal file
96
src/lib/snapshot-name.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Snapshot of the BNS name beacon: raw history + verbose tx cache, plus a
|
||||
// deterministic root over the ordered history. Consumed by Theseus and the
|
||||
// mobile resolver to skip the "walk the whole beacon on cold start" round
|
||||
// trip — buildIndex (bns.js / resolver-web.js) reads the txs directly.
|
||||
//
|
||||
// Design goals:
|
||||
// 1. **Interpretation-free.** The snapshot is the raw beacon history + tx
|
||||
// cache. It embeds ZERO indexer-side interpretation (no REG/UPD/first-wins
|
||||
// decisions), so consumers built on any resolver version still work
|
||||
// against it. Adding a new opcode does not require re-mirroring.
|
||||
// 2. **Deterministic root.** Any honest producer looking at the same chain
|
||||
// state up to (asOfHeight, asOfTxid) produces byte-identical bytes,
|
||||
// hence the same sha256 root. This is what a client pins.
|
||||
// 3. **Delta-friendly.** history is sorted (height, txid). To catch up, a
|
||||
// consumer asks any electrum for the FULL history and skips any (tx_hash)
|
||||
// already in the snapshot. It only fetches the verbose tx for the new
|
||||
// ones. Big win because getting a verbose tx is the expensive round-trip.
|
||||
//
|
||||
// Schema slug is baked in so future breaking changes can migrate cleanly.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const NAME_SNAPSHOT_SCHEMA = "bns-name-snapshot/1";
|
||||
|
||||
function byHeightThenTxid(a, b) {
|
||||
const ha = a.height > 0 ? a.height : Infinity;
|
||||
const hb = b.height > 0 ? b.height : Infinity;
|
||||
return ha - hb || (a.tx_hash < b.tx_hash ? -1 : 1);
|
||||
}
|
||||
|
||||
// Canonicalise the history: (height, txid)-sorted, minimal object shape.
|
||||
// Root is over THIS, not txs — the txs are already content-addressed by their
|
||||
// txids, which appear in history verbatim. A tampered tx cache still parses
|
||||
// as valid JSON but breaks buildIndex visibly on the consumer.
|
||||
function canonicaliseHistory(history) {
|
||||
const sorted = [...history].sort(byHeightThenTxid).map((h) => ({
|
||||
tx_hash: String(h.tx_hash),
|
||||
height: Number(h.height),
|
||||
}));
|
||||
return JSON.stringify(sorted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a name-beacon snapshot.
|
||||
*
|
||||
* @param {object} inputs
|
||||
* @param {string} inputs.beacon - beacon scripthash, hex.
|
||||
* @param {Array<{tx_hash: string, height: number}>} inputs.history - electrum-shape history.
|
||||
* @param {Map<string, object> | Record<string, object>} inputs.txs - verbose tx cache keyed by txid.
|
||||
* @param {number|null} [inputs.asOfHeight] - BCHN tip at snapshot time (may be null).
|
||||
* @param {string|null} [inputs.asOfTxid] - hash of block at asOfHeight (may be null).
|
||||
* @returns {{ schema: string, beacon: string, root: string, asOfHeight: number|null, asOfTxid: string|null, history: Array, txs: Object }}
|
||||
*/
|
||||
export function buildNameSnapshot({ beacon, history = [], txs, asOfHeight = null, asOfTxid = null } = {}) {
|
||||
if (!beacon || typeof beacon !== "string") throw new Error("buildNameSnapshot: beacon (scripthash hex) required");
|
||||
const txMap = txs instanceof Map ? Object.fromEntries(txs) : (txs || {});
|
||||
const canon = canonicaliseHistory(history);
|
||||
const root = createHash("sha256").update(canon, "utf8").digest("hex");
|
||||
return {
|
||||
schema: NAME_SNAPSHOT_SCHEMA,
|
||||
beacon,
|
||||
root,
|
||||
asOfHeight,
|
||||
asOfTxid,
|
||||
history: JSON.parse(canon), // return the sorted normalised copy — same bytes the root is over
|
||||
txs: txMap,
|
||||
};
|
||||
}
|
||||
|
||||
// Merge a snapshot with fresh electrum history to catch up without re-fetching
|
||||
// every verbose tx. Returns `{ history, txs, addedTxids }` where txs is the
|
||||
// UNION (snapshot cache + newly fetched), so buildIndex can walk it directly.
|
||||
//
|
||||
// `fetchTx(txid) -> Promise<verboseTx>` is provided by the caller (electrum
|
||||
// client, HTTP fetch of raw hex + decode, whatever); this function stays I/O-
|
||||
// agnostic. It is safe to pass the same snapshot's history as freshHistory —
|
||||
// the result is a no-op merge.
|
||||
export async function mergeFreshHistory({ snapshot, freshHistory, fetchTx }) {
|
||||
const known = new Set(snapshot.history.map((h) => h.tx_hash));
|
||||
const merged = new Map(snapshot.history.map((h) => [h.tx_hash, h]));
|
||||
const txs = new Map(Object.entries(snapshot.txs || {}));
|
||||
const addedTxids = [];
|
||||
for (const h of freshHistory) {
|
||||
if (!known.has(h.tx_hash)) {
|
||||
addedTxids.push(h.tx_hash);
|
||||
try { txs.set(h.tx_hash, await fetchTx(h.tx_hash)); }
|
||||
catch { /* unreadable — skip; consumer's buildIndex ignores missing tx */ }
|
||||
}
|
||||
merged.set(h.tx_hash, { tx_hash: h.tx_hash, height: h.height });
|
||||
}
|
||||
return {
|
||||
history: [...merged.values()].sort(byHeightThenTxid),
|
||||
txs,
|
||||
addedTxids,
|
||||
};
|
||||
}
|
||||
156
src/publish-name-mirror.mjs
Normal file
156
src/publish-name-mirror.mjs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Publish the current NAME-beacon snapshot to public mirrors, alongside the
|
||||
// TLD-beacon mirror (publish-tld-mirror.mjs). Same shape:
|
||||
// • Sia S3 — floating key (bns/name-list.json) and content-addressed
|
||||
// immutable key (bns/name-list-<root>.json). Consumers pin the root; the
|
||||
// floating key is the "latest" pointer they refresh on a cron.
|
||||
// • Nostr — NIP-33 replaceable event (kind 30078, d-tag bns-name-list)
|
||||
// carrying {root, asOfHeight, asOfTxid, count, sia_url}, signed by the
|
||||
// operator's Nostr key (Hermes derivation from the wallet seed).
|
||||
//
|
||||
// Why mirror the name beacon at all: a Theseus/mobile install today walks the
|
||||
// whole beacon on cold start via electrum. The verbose-tx fetches are the
|
||||
// expensive step and repeat verbatim on every device. Publishing the raw
|
||||
// history + tx cache means a client's cold start becomes:
|
||||
// GET floating pointer (a few KB)
|
||||
// → GET immutable snapshot for that root (~350 KB @ 72 beacon txs today)
|
||||
// → electrum delta for whatever landed after asOfHeight
|
||||
// → local buildIndex on the union
|
||||
// Rounds trips: three, all cacheable. Same trust model as before — the raw
|
||||
// beacon txs are still cryptographic evidence; the mirror is just a fast lane.
|
||||
//
|
||||
// Environment:
|
||||
// BNS_MIRROR_SIA_BUCKET default "bns"
|
||||
// BNS_MIRROR_SIA_PREFIX default "" — set to "name/" to nest
|
||||
// BNS_MIRROR_NOSTR_RELAYS default "wss://nos.lol,wss://relay.damus.io"
|
||||
// BNS_MIRROR_DRY_RUN=1 build + hash + log, do NOT publish anywhere
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { S3Client, PutObjectCommand, HeadBucketCommand, CreateBucketCommand } from "@aws-sdk/client-s3";
|
||||
import WebSocket from "ws";
|
||||
import { finalizeEvent } from "nostr-tools/pure";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { loadWallet } from "./lib/wallet.js";
|
||||
import { buildNameSnapshot } from "./lib/snapshot-name.js";
|
||||
import { BEACON, WALLET_FILE } from "./config.js";
|
||||
import { nostrKeyFromMnemonic } from "./lib/hermes-derive.js";
|
||||
|
||||
// The beacon's scripthash — the electrum layer indexes by scripthash, not
|
||||
// cashaddr. Kept in step with resolver-web.js:BEACON_SCRIPTHASH.
|
||||
const BEACON_SCRIPTHASH = "e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b";
|
||||
|
||||
const DRY = process.env.BNS_MIRROR_DRY_RUN === "1";
|
||||
const BUCKET = process.env.BNS_MIRROR_SIA_BUCKET || "bns";
|
||||
const PREFIX = process.env.BNS_MIRROR_SIA_PREFIX || "";
|
||||
const RELAYS = (process.env.BNS_MIRROR_NOSTR_RELAYS || "wss://nos.lol,wss://relay.damus.io")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||
|
||||
// ---------- 1) build snapshot ----------
|
||||
log("building snapshot from name beacon", BEACON, "…");
|
||||
const wallet = await loadWallet("main");
|
||||
const history = await wallet.provider.getHistory(BEACON);
|
||||
log(`beacon history : ${history.length} txs`);
|
||||
const txs = new Map();
|
||||
for (const { tx_hash } of history) {
|
||||
try { txs.set(tx_hash, await wallet.provider.getRawTransactionObject(tx_hash)); }
|
||||
catch { /* skip; the snapshot format tolerates missing tx entries */ }
|
||||
}
|
||||
log(`verbose txs : ${txs.size}/${history.length} fetched`);
|
||||
|
||||
const snap = buildNameSnapshot({
|
||||
beacon: BEACON_SCRIPTHASH,
|
||||
history,
|
||||
txs,
|
||||
// asOfHeight/asOfTxid: no BCHN tip here (wallet's electrum provider doesn't
|
||||
// expose it), so nulls. The indexer's own publisher will fill these when we
|
||||
// migrate the publisher to run VPS-side against the local BCHN.
|
||||
asOfHeight: null,
|
||||
asOfTxid: null,
|
||||
});
|
||||
const body = JSON.stringify(snap);
|
||||
log(`snapshot bytes : ${Buffer.byteLength(body)}, root=${snap.root}`);
|
||||
|
||||
if (DRY) {
|
||||
log("BNS_MIRROR_DRY_RUN=1 — not publishing.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ---------- 2) publish to Sia (floating + content-addressed) ----------
|
||||
let siaUrl = null;
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(new URL("../sia-s3.json", import.meta.url), "utf8"));
|
||||
const s3 = new S3Client({
|
||||
endpoint: cfg.endpoint ?? "http://127.0.0.1:8000",
|
||||
region: "us-east-1",
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey },
|
||||
});
|
||||
try { await s3.send(new HeadBucketCommand({ Bucket: BUCKET })); }
|
||||
catch { await s3.send(new CreateBucketCommand({ Bucket: BUCKET })); log(`created bucket "${BUCKET}"`); }
|
||||
|
||||
const floating = `${PREFIX}name-list.json`;
|
||||
const immutable = `${PREFIX}name-list-${snap.root}.json`;
|
||||
for (const key of [floating, immutable]) {
|
||||
try {
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET, Key: key, Body: body,
|
||||
ContentType: "application/json",
|
||||
CacheControl: key === floating ? "public, max-age=60" : "public, max-age=31536000, immutable",
|
||||
ACL: "public-read",
|
||||
}));
|
||||
} catch (aclErr) {
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET, Key: key, Body: body,
|
||||
ContentType: "application/json",
|
||||
CacheControl: key === floating ? "public, max-age=60" : "public, max-age=31536000, immutable",
|
||||
}));
|
||||
log(` (ACL=public-read rejected for ${key}: ${aclErr.message} — uploaded without ACL; operator must set bucket policy)`);
|
||||
}
|
||||
log(` uploaded s3://${BUCKET}/${key}`);
|
||||
}
|
||||
siaUrl = `${cfg.endpoint.replace(/\/$/, "")}/${BUCKET}/${immutable}`;
|
||||
log(`sia URL (immutable): ${siaUrl}`);
|
||||
} catch (e) {
|
||||
log(`SIA UPLOAD FAILED: ${e.message}`);
|
||||
log("continuing to Nostr anyway — event without sia_url still carries the root");
|
||||
}
|
||||
|
||||
// ---------- 3) publish NIP-33 event to Nostr ----------
|
||||
const store = JSON.parse(readFileSync(fileURLToPath(WALLET_FILE), "utf8"));
|
||||
const { skHex, pkHex } = await nostrKeyFromMnemonic(store.main.seed);
|
||||
log(`nostr pubkey : ${pkHex}`);
|
||||
|
||||
const content = JSON.stringify({
|
||||
root: snap.root,
|
||||
asOfHeight: snap.asOfHeight,
|
||||
asOfTxid: snap.asOfTxid,
|
||||
count: snap.history.length,
|
||||
sia_url: siaUrl,
|
||||
});
|
||||
const createdAt = Math.floor(Date.parse(new Date().toISOString()) / 1000);
|
||||
const kind = 30078;
|
||||
const tags = [["d", "bns-name-list"]];
|
||||
const sk = Uint8Array.from(Buffer.from(skHex, "hex"));
|
||||
const event = finalizeEvent({ kind, created_at: createdAt, tags, content }, sk);
|
||||
log(`nostr event id : ${event.id}`);
|
||||
|
||||
async function publishOne(url) {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(url);
|
||||
const done = (status, detail) => { try { ws.close(); } catch {} resolve({ url, status, detail }); };
|
||||
ws.on("open", () => { ws.send(JSON.stringify(["EVENT", event])); });
|
||||
ws.on("message", (d) => {
|
||||
let msg; try { msg = JSON.parse(d.toString()); } catch { return; }
|
||||
if (msg[0] === "OK" && msg[1] === event.id) done(msg[2] ? "ok" : "rejected", msg[3] || "");
|
||||
});
|
||||
ws.on("error", (e) => done("error", e.message));
|
||||
setTimeout(() => done("timeout", ""), 8000);
|
||||
});
|
||||
}
|
||||
const results = await Promise.all(RELAYS.map(publishOne));
|
||||
for (const r of results) log(` relay ${r.url.padEnd(30)} → ${r.status}${r.detail ? " (" + r.detail + ")" : ""}`);
|
||||
const okCount = results.filter((r) => r.status === "ok").length;
|
||||
log(`nostr: ${okCount}/${results.length} relays accepted`);
|
||||
process.exit(0);
|
||||
84
src/register-electrum-list.mjs
Normal file
84
src/register-electrum-list.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Publish the current chipnet electrum-server pool to the on-chain name
|
||||
// `electrum.silentmode.bch`, so clients read the fresh list at bootstrap and
|
||||
// refresh their pool without shipping a new build. Idempotent: if the name
|
||||
// already exists, does an UPD; otherwise a REG.
|
||||
//
|
||||
// node src/register-electrum-list.mjs
|
||||
//
|
||||
// The pool source of truth here is the CHIPNET_ELECTRUM seed in
|
||||
// resolver-web.js — keep them in step. Adding a new VPS: append to the seed
|
||||
// AND re-run this. The `el` record format is a space-separated list of
|
||||
// `wss://host:port|ip` tokens (ip optional; if given, clients can dial
|
||||
// DNS-independent via SNI).
|
||||
|
||||
import { loadWallet } from "./lib/wallet.js";
|
||||
import { registerName, updateName, buildIndex, normalizeName } from "./lib/bns.js";
|
||||
import { CHIPNET_ELECTRUM } from "./lib/resolver-web.js";
|
||||
|
||||
// electrum.bch — normalises to bare `electrum` in the index, a distinct name
|
||||
// with no collision. NOT `electrum.silentmode.bch`, which collapses to
|
||||
// silentmode.bch under normalizeName's <sub>.<label>.<tld> rule and would
|
||||
// overwrite that name's records on UPD. (History: earlier revision of this
|
||||
// script did exactly that and had to revert. See revert-silentmode-bch.mjs.)
|
||||
const NAME = "electrum.bch";
|
||||
|
||||
// Build the `el` string exactly as the resolver expects: "wss://host:port|ip"
|
||||
// tokens, space-separated. Order matches the seed so the on-chain list and
|
||||
// the shipped fallback agree on priority.
|
||||
function toElRecord(servers) {
|
||||
return servers
|
||||
.filter((s) => /^wss?:\/\//i.test(s.url))
|
||||
.map((s) => s.ip ? `${s.url}|${s.ip}` : s.url)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// The OP_RETURN payload is capped at 200 bytes by encodePayload. Envelope
|
||||
// (BNS1 + JSON overhead) for `electrum.silentmode.bch` eats ~60 bytes, so
|
||||
// the `el` value has ~140 bytes to work with. That's enough for the primary
|
||||
// server with its IP pin plus 1-2 public fallbacks unpinned. Drop the
|
||||
// remaining fallbacks from the record — they still live in CHIPNET_ELECTRUM
|
||||
// as the shipped seed, so no client loses reachability from this trim.
|
||||
function fitToPayloadBudget(servers, budget = 140) {
|
||||
const out = [];
|
||||
let used = 0;
|
||||
for (const s of servers) {
|
||||
if (!/^wss?:\/\//i.test(s.url)) continue;
|
||||
const tok = s.ip ? `${s.url}|${s.ip}` : s.url;
|
||||
const cost = tok.length + (out.length ? 1 : 0); // +space between tokens
|
||||
if (used + cost > budget) continue;
|
||||
out.push(tok);
|
||||
used += cost;
|
||||
}
|
||||
return out.join(" ");
|
||||
}
|
||||
const el = fitToPayloadBudget(CHIPNET_ELECTRUM);
|
||||
if (!el) { console.error("CHIPNET_ELECTRUM is empty — nothing to publish"); process.exit(1); }
|
||||
console.log("target name :", NAME);
|
||||
console.log("record :", el);
|
||||
console.log("record bytes :", Buffer.byteLength(el), "(cap ~140)");
|
||||
|
||||
const wallet = await loadWallet("main");
|
||||
console.log("from wallet :", wallet.cashaddr);
|
||||
const bal = await wallet.getBalance("sat");
|
||||
console.log("wallet balance :", bal, "sat\n");
|
||||
|
||||
const key = normalizeName(NAME); // "electrum.silentmode.bch" (or bare label if <TLD>==bch)
|
||||
const idx = await buildIndex(wallet.provider);
|
||||
const existing = idx.get(key);
|
||||
|
||||
let result;
|
||||
if (existing) {
|
||||
console.log(`existing REG at height ${existing.height} — signing UPD…`);
|
||||
result = await updateName(wallet, NAME, { el });
|
||||
} else {
|
||||
console.log("no existing REG — minting fresh…");
|
||||
result = await registerName(wallet, NAME, { el });
|
||||
}
|
||||
|
||||
console.log("\n✅ broadcast");
|
||||
console.log(" name :", result.name);
|
||||
console.log(" txid :", result.txid);
|
||||
console.log(" category :", result.category);
|
||||
console.log("\nTakes effect at the next chipnet block (~seconds). Clients that");
|
||||
console.log("resolve", NAME, "after that will refresh their pool from this record.");
|
||||
process.exit(0);
|
||||
27
src/revert-silentmode-bch.mjs
Normal file
27
src/revert-silentmode-bch.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// One-off: revert silentmode.bch's records to the state before the accidental
|
||||
// UPD in txid 4fa572b2337ec7844364bfe9fea613b2f8283aba7d7dd46ede73c807c2834987.
|
||||
// That UPD happened because bns.js:normalizeName collapses "electrum.silentmode.bch"
|
||||
// down to "silentmode" (subdomain of a .bch name → parent name), which the
|
||||
// register-electrum-list script did not account for. The prior on-chain
|
||||
// records were {"s3":"bns/silentmode/"} — recovered from the local name
|
||||
// snapshot (Argus/src/snapshot-name-to-file.mjs) which was generated BEFORE
|
||||
// the accidental UPD.
|
||||
//
|
||||
// After this runs, register-electrum-list.mjs needs a rework that stops
|
||||
// treating "electrum.silentmode.bch" as a single BCNR name (it isn't — under
|
||||
// the .bch TLD it's a subdomain that shares silentmode.bch's records). The
|
||||
// real fix is to move ELECTRUM_LIST_NAME to a name whose canonical key is
|
||||
// distinct — e.g. "electrum.sm" once .sm is TREG'd, or "electrum-list.bch".
|
||||
|
||||
import { loadWallet } from "./lib/wallet.js";
|
||||
import { updateName } from "./lib/bns.js";
|
||||
|
||||
const wallet = await loadWallet("main");
|
||||
console.log("from wallet :", wallet.cashaddr);
|
||||
console.log("reverting silentmode.bch records → {\"s3\":\"bns/silentmode/\"} …");
|
||||
const result = await updateName(wallet, "silentmode", { s3: "bns/silentmode/" });
|
||||
console.log("\n✅ broadcast");
|
||||
console.log(" name :", result.name);
|
||||
console.log(" txid :", result.txid);
|
||||
console.log(" category :", result.category);
|
||||
process.exit(0);
|
||||
48
src/snapshot-name-to-file.mjs
Normal file
48
src/snapshot-name-to-file.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Build the name-beacon snapshot and write it to a local file. Same code
|
||||
// path publish-name-mirror.mjs uses; skips Sia/Nostr publishing. Useful for
|
||||
// bundling a starter snapshot into Theseus at build time.
|
||||
//
|
||||
// node src/snapshot-name-to-file.mjs [outfile]
|
||||
//
|
||||
// Default outfile: TheseusNavigator/snapshots/bns-name-snapshot.json
|
||||
// (the path main.js reads as SNAPSHOT_BUNDLED).
|
||||
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
import { loadWallet } from "./lib/wallet.js";
|
||||
import { buildNameSnapshot } from "./lib/snapshot-name.js";
|
||||
import { BEACON } from "./config.js";
|
||||
|
||||
const BEACON_SCRIPTHASH = "e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b";
|
||||
const OUT = process.argv[2] || fileURLToPath(
|
||||
new URL("../../TheseusNavigator/snapshots/bns-name-snapshot.json", import.meta.url),
|
||||
);
|
||||
|
||||
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||
|
||||
log("building snapshot from name beacon", BEACON, "…");
|
||||
const wallet = await loadWallet("main");
|
||||
const history = await wallet.provider.getHistory(BEACON);
|
||||
log(`beacon history : ${history.length} txs`);
|
||||
const txs = new Map();
|
||||
for (const { tx_hash } of history) {
|
||||
try { txs.set(tx_hash, await wallet.provider.getRawTransactionObject(tx_hash)); }
|
||||
catch { /* skip */ }
|
||||
}
|
||||
log(`verbose txs : ${txs.size}/${history.length}`);
|
||||
|
||||
const snap = buildNameSnapshot({
|
||||
beacon: BEACON_SCRIPTHASH,
|
||||
history,
|
||||
txs,
|
||||
asOfHeight: null,
|
||||
asOfTxid: null,
|
||||
});
|
||||
const body = JSON.stringify(snap);
|
||||
mkdirSync(path.dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, body);
|
||||
log(`wrote ${Buffer.byteLength(body)} bytes to ${OUT}`);
|
||||
log(`root: ${snap.root}`);
|
||||
process.exit(0);
|
||||
Loading…
Add table
Reference in a new issue