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.
151 lines
5.7 KiB
JavaScript
151 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 });
|
||
}
|