argus/src/lib/name-index.js

152 lines
5.7 KiB
JavaScript
Raw Normal View History

// 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/<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<name, entry>, 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 });
}