// A local name index, so availability search never tells anyone what you typed. // // WHY THIS EXISTS — it is a privacy property, not an optimisation. // // The register page used to answer "is this name free?" by calling // `navigate.st/api/name/`. That works, but it means the registry operator // sees every name anyone considers, before they register it — which is a better // front-running position than any miner has. We spent real effort designing // commit–reveal so miners cannot snipe a registration from the mempool; leaving // search on a server we run would hand that same advantage straight back to // ourselves, and no amount of promising not to look is checkable by anyone. // // So the lookup reads the chain directly and the answer is computed in the // buyer's own browser. Not "we don't log searches" — there is no search to log. // // THE PROBLEM THAT MAKES THIS NON-TRIVIAL // `buildIndex()` fetches the beacon's whole history and then every transaction in // it. That is fine for a registry of 16 names and hopeless for one of 100,000, // and search-as-you-type would re-do it on every keystroke. // // The fix is to cache the FETCHES, not the index. Beacon history is append-only, // so a second visit needs only the transactions it has not seen. Rebuilding the // index from cached data is pure local computation — microseconds — and it keeps // the ordering and dependency rules in exactly one place rather than trying to // mutate an existing index incrementally (which would need its own reorg and // causal-ordering logic, i.e. a second implementation of the hard part). import { BEACON_SCRIPTHASH } from "./resolver-web.js"; const CACHE_KEY = "bns.index.v1"; /** Cheap fingerprint of the fetched set, to notice a reorg or a pruned server. */ function fingerprint(history) { return `${history.length}:${history[history.length - 1]?.tx_hash ?? ""}`; } export class NameIndex { constructor({ storage = null } = {}) { this.storage = storage; this.txs = new Map(); // txid -> verbose transaction this.history = []; // [{tx_hash, height}] this.index = null; // Map, rebuilt after each sync this.lastSyncedAt = 0; } /** Restore the fetch cache. Safe to call with no storage. */ load() { if (!this.storage) return false; try { const raw = this.storage.getItem(CACHE_KEY); if (!raw) return false; const parsed = JSON.parse(raw); this.history = parsed.history ?? []; this.txs = new Map(Object.entries(parsed.txs ?? {})); this.lastSyncedAt = parsed.lastSyncedAt ?? 0; return this.history.length > 0; } catch { return false; // a corrupt cache is not worth recovering — refetch } } save() { if (!this.storage) return; try { this.storage.setItem( CACHE_KEY, JSON.stringify({ history: this.history, txs: Object.fromEntries(this.txs), lastSyncedAt: this.lastSyncedAt, }), ); } catch { // Quota exceeded, private browsing, whatever. Caching is an optimisation; // failing to cache must never fail a lookup. } } /** * Fetch whatever is new and rebuild. * * @param client an Electrum client or ElectrumPool * @returns {{fetched: number, cached: number, names: number}} */ async sync(client, { now = 0 } = {}) { // A pool gives us the UNION across servers, which is what defends against a // server hiding a registration. Fall back to a plain call for a single client. const history = client.unionHistory ? (await client.unionHistory(BEACON_SCRIPTHASH)).history : await client.call("blockchain.scripthash.get_history", [BEACON_SCRIPTHASH]); // If the set shrank, something is wrong (reorg, or a server with less data). // Drop the cache rather than trust a merge of two disagreeing views. if (history.length < this.history.length) { this.txs.clear(); } this.history = history; let fetched = 0; for (const { tx_hash } of history) { if (this.txs.has(tx_hash)) continue; try { this.txs.set(tx_hash, await client.call("blockchain.transaction.get", [tx_hash, true])); fetched++; } catch { // Leave it unfetched; buildFrom skips what it cannot read, and the next // sync will try again. } } this.lastSyncedAt = now; this.index = await buildFromCache(this.history, this.txs); this.save(); return { fetched, cached: this.txs.size - fetched, names: this.index.size }; } /** Local lookup. No network, no request, nothing observable. */ lookup(canonicalName) { if (!this.index) throw new Error("call sync() before lookup()"); return this.index.get(canonicalName) ?? null; } get size() { return this.index?.size ?? 0; } get fingerprint() { return fingerprint(this.history); } } /** * Rebuild the index from cached fetches, reusing resolver-web's rules. * * The ordering and validity rules live in `resolver-web.js` and must not be * duplicated here — a second copy is a second thing to get wrong, and the two * disagreeing is a consensus split between clients. So we hand it a fake client * that serves from cache and let it do the work it already does correctly. */ export async function buildFromCache(history, txs) { const { buildIndex } = await import("./resolver-web.js"); const fakeClient = { async call(method, params) { if (method === "blockchain.scripthash.get_history") return history; if (method === "blockchain.transaction.get") { const tx = txs.get(params[0]); if (!tx) throw new Error("not cached"); return tx; } if (method === "server.version") return ["cache", "1.4.2"]; return null; }, close() {}, }; return buildIndex({ client: fakeClient }); }