152 lines
5.7 KiB
JavaScript
152 lines
5.7 KiB
JavaScript
|
|
// 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 });
|
|||
|
|
}
|