argus/src/lib/name-index.js
Local Dev f9a5eb367f Theseus navigator UI overhaul, BNS-only indexer, site restructure
Theseus Navigator:
- Firefox-style toolbar: SVG back/fwd/reload/home buttons
- security padlock badge in the address bar opens a floating site-info
  overlay VIEW (on top of the page, never pushes content down)
- Edge-style favorites bar (shown only on the new-tab/home page)
- search-engine picker (DuckDuckGo/Google/Bing/Brave/Startpage)
- Theseus logo moved right, opens Settings; native menu removed
- address bar doubles as search; bottom resolver status line

Argus:
- BNS-only indexer: an electrum endpoint over a bare BCHN node
  (watches just the beacon, caches txs so no txindex needed) + test
- registry lifecycle / portfolio / name-index modules

Site restructure (nav/apps/store/hermes/choose) + runbook notes.
2026-07-30 08:16:55 +02:00

151 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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