From f9a5eb367f45853550f6eeecd1df225b8cc6a4a8 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 30 Jul 2026 08:16:55 +0200 Subject: [PATCH] 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. --- src/indexer/README.md | 124 +++++++++++++ src/indexer/bns-indexer.js | 245 ++++++++++++++++++++++++++ src/lib/name-index.js | 151 ++++++++++++++++ src/lib/portfolio.js | 147 ++++++++++++++++ src/lib/registry-lifecycle.js | 231 ++++++++++++++++++++++++ src/resolve.js | 5 +- test/bns-indexer.test.mjs | 83 +++++++++ test/portfolio-index.test.mjs | 292 +++++++++++++++++++++++++++++++ test/registry-lifecycle.test.mjs | 261 +++++++++++++++++++++++++++ 9 files changed, 1538 insertions(+), 1 deletion(-) create mode 100644 src/indexer/README.md create mode 100644 src/indexer/bns-indexer.js create mode 100644 src/lib/name-index.js create mode 100644 src/lib/portfolio.js create mode 100644 src/lib/registry-lifecycle.js create mode 100644 test/bns-indexer.test.mjs create mode 100644 test/portfolio-index.test.mjs create mode 100644 test/registry-lifecycle.test.mjs diff --git a/src/indexer/README.md b/src/indexer/README.md new file mode 100644 index 0000000..9c3908a --- /dev/null +++ b/src/indexer/README.md @@ -0,0 +1,124 @@ +# BNS-only indexer + +A featherweight electrum endpoint for a bare **BCHN** full node. It watches the +single BNS beacon address and serves only the three electrum methods the resolver +uses (`server.version`, `blockchain.scripthash.get_history` for the beacon, +`blockchain.transaction.get`). Beacon transactions are cached as blocks are +scanned, so **the node needs no `txindex`**. + +Result: `node + bns-indexer` instead of `node + Fulcrum` — a much smaller footprint +that fits a 1‑core / 3 GB VPS, and doubles as your own pinned‑IP, DNS‑independent +bootstrap server for Theseus. + +It is protocol‑verified against the public chipnet servers by +`Argus/test/bns-indexer.test.mjs` (resolves a real name through the indexer and +diffs the result byte‑for‑byte). + +--- + +## 1. Bitcoin Cash Node (BCHN), chipnet + +Install BCHN (https://bitcoincashnode.org), then `~/.bitcoin/bitcoin.conf`: + +```ini +chipnet=1 +server=1 +# no txindex needed — the indexer caches beacon txs while scanning blocks +# do NOT prune: the first backfill scans historical blocks +rpcuser=bns +rpcpassword=CHANGE_ME_TO_SOMETHING_LONG +rpcbind=127.0.0.1 +rpcallowip=127.0.0.1 +[chipnet] +rpcport=48332 +``` + +Start it and let it fully sync: `bitcoind -daemon` +Check: `bitcoin-cli -chipnet getblockchaininfo` + +## 2. Run the indexer + +Needs Node 18+ and `ws` (already in Argus's deps). Config is via env: + +```bash +BNS_RPC_URL=http://127.0.0.1:48332 \ +BNS_RPC_USER=bns BNS_RPC_PASS=CHANGE_ME_TO_SOMETHING_LONG \ +BNS_WS_BIND=127.0.0.1 BNS_WS_PORT=50010 \ +BNS_START_HEIGHT=0 \ +BNS_STATE_FILE=/opt/silent-mode/bns-indexer-state.json \ +node src/indexer/bns-indexer.js +``` + +- `BNS_START_HEIGHT` — set to the beacon's first-seen height to shrink the one‑time + first scan (leave 0 to scan from genesis; chipnet is small, so this is fine but slower). +- The first run backfills to the tip (progress is logged every 1000 blocks) and then + follows new blocks every `BNS_POLL_MS` (default 10 s). State persists to + `BNS_STATE_FILE`, so restarts don't rescan. +- Add `BNS_MEMPOOL=1` to also surface unconfirmed registrations (height 0). + +### systemd unit — `/etc/systemd/system/bns-indexer.service` + +```ini +[Unit] +Description=BNS-only indexer (electrum endpoint for BCHN) +After=network.target bitcoind.service + +[Service] +WorkingDirectory=/opt/silent-mode/Argus +Environment=BNS_RPC_URL=http://127.0.0.1:48332 +Environment=BNS_RPC_USER=bns +Environment=BNS_RPC_PASS=CHANGE_ME_TO_SOMETHING_LONG +Environment=BNS_WS_PORT=50010 +Environment=BNS_STATE_FILE=/opt/silent-mode/bns-indexer-state.json +ExecStart=/usr/bin/node src/indexer/bns-indexer.js +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +`systemctl enable --now bns-indexer` + +## 3. TLS: expose it as `wss://` via nginx + +The indexer serves plain `ws://127.0.0.1:50010`. Terminate TLS at nginx using the +existing Let's Encrypt cert for a hostname you control (e.g. `coinspectrum.duckdns.org`), +so the certificate validates — including for Theseus's pinned‑IP + SNI path. + +```nginx +server { + listen 50011 ssl; + server_name coinspectrum.duckdns.org; + ssl_certificate /etc/letsencrypt/live/coinspectrum.duckdns.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/coinspectrum.duckdns.org/privkey.pem; + location / { + proxy_pass http://127.0.0.1:50010; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + } +} +``` + +Open the port (`ufw allow 50011/tcp`) and reload nginx. +Test from a machine with working DNS: `wss://coinspectrum.duckdns.org:50011`. + +## 4. Wire it into Theseus + +Two places (see `Argus/src/lib/resolver-web.js`): + +1. **Seed (optional anchor).** Add your server to `CHIPNET_ELECTRUM` with its pinned IP: + ```js + { url: "wss://coinspectrum.duckdns.org:50011", ip: "195.184.247.106" } + ``` + Because the IP is pinned, Theseus reaches it even when the user's DNS is dead. + +2. **On-chain discovery.** Publish the `electrum.silentmode.bch` name with an `el` + record (space‑separated `url` or `url|ip` tokens): + ``` + el = "wss://coinspectrum.duckdns.org:50011|195.184.247.106 wss://chipnet.bch.ninja:50004|135.148.236.244" + ``` + Theseus reads this after bootstrap and refreshes its pool from it — so you can + rotate servers on‑chain without shipping a new build. diff --git a/src/indexer/bns-indexer.js b/src/indexer/bns-indexer.js new file mode 100644 index 0000000..20bb25c --- /dev/null +++ b/src/indexer/bns-indexer.js @@ -0,0 +1,245 @@ +// BNS-only indexer — a featherweight electrum endpoint for a bare BCHN full node. +// +// Why this exists: Theseus/Ariadne resolve names by asking an electrum server for +// the FULL history of ONE address (the BNS beacon). A stock full node can't answer +// address-history queries (no address index) and doesn't speak electrum — that's +// what Fulcrum adds, by indexing EVERY address. But BNS only cares about the beacon, +// so instead of a full indexer we watch that single address and serve just the three +// electrum methods the resolver uses: +// server.version +// blockchain.scripthash.get_history (beacon only) +// blockchain.transaction.get (served from a cache filled while scanning, +// so the node needs no txindex) +// +// Data source is a BCHN full node over JSON-RPC. Blocks are pulled with verbosity 2 +// (full decoded txs), so we get scriptPubKey hex + CashToken tokenData in one call +// and cache the beacon txs verbatim — the exact shape resolver-web.js expects. +// +// Run: node bns-indexer.js (config via env — see CONFIG below) +import http from "node:http"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { WebSocketServer } from "ws"; + +// ---- config (env, with sane chipnet defaults) ---- +const CONFIG = { + rpcUrl: process.env.BNS_RPC_URL || "http://127.0.0.1:48332", // BCHN chipnet rpcport + rpcUser: process.env.BNS_RPC_USER || "", + rpcPass: process.env.BNS_RPC_PASS || "", + rpcCookie: process.env.BNS_RPC_COOKIE || "", // path to .cookie (alternative to user/pass) + wsBind: process.env.BNS_WS_BIND || "127.0.0.1", + wsPort: Number(process.env.BNS_WS_PORT || 50010), + // The beacon scripthash the resolver scans (constant; keep in sync with resolver-web.js). + beacon: process.env.BNS_BEACON_SCRIPTHASH || + "e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b", + // Skip blocks before BNS existed on chipnet — set this to shrink the first scan. + startHeight: Number(process.env.BNS_START_HEIGHT || 0), + pollMs: Number(process.env.BNS_POLL_MS || 10000), + scanMempool: process.env.BNS_MEMPOOL === "1", + stateFile: process.env.BNS_STATE_FILE || "./bns-indexer-state.json", +}; + +const log = (...a) => console.log(new Date().toISOString(), ...a); + +// electrum scripthash of a scriptPubKey = sha256(script) bytes, reversed, hex. +export function scripthashOf(scriptHex) { + const h = createHash("sha256").update(Buffer.from(scriptHex, "hex")).digest(); + return Buffer.from(h).reverse().toString("hex"); +} + +// ---- BCHN JSON-RPC ---- +function makeRpc(cfg) { + let auth = ""; + if (cfg.rpcCookie && existsSync(cfg.rpcCookie)) auth = readFileSync(cfg.rpcCookie, "utf8").trim(); + else if (cfg.rpcUser) auth = `${cfg.rpcUser}:${cfg.rpcPass}`; + const url = new URL(cfg.rpcUrl); + return (method, params = []) => new Promise((resolve, reject) => { + const body = JSON.stringify({ jsonrpc: "1.0", id: "bns", method, params }); + const req = http.request({ + hostname: url.hostname, port: url.port, path: url.pathname || "/", method: "POST", + headers: { + "content-type": "application/json", "content-length": Buffer.byteLength(body), + ...(auth ? { authorization: "Basic " + Buffer.from(auth).toString("base64") } : {}), + }, + }, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + try { + const j = JSON.parse(Buffer.concat(chunks).toString("utf8")); + j.error ? reject(new Error(j.error.message || JSON.stringify(j.error))) : resolve(j.result); + } catch (e) { reject(e); } + }); + }); + req.on("error", reject); + req.end(body); + }); +} + +// ---- the beacon index (source-independent; this is what the WS layer serves) ---- +export class BeaconIndex { + constructor(beacon) { + this.beacon = beacon; + this.history = new Map(); // txid -> height (0 = mempool) + this.txCache = new Map(); // txid -> verbose tx (as returned by getblock v2) + this.utxos = new Set(); // "txid:n" outputs paying the beacon (to detect spends) + } + // Record a tx if it touches the beacon (pays it, or spends one of its outputs). + processTx(tx, height) { + let touches = false; + const vout = tx.vout || []; + for (let n = 0; n < vout.length; n++) { + const hex = vout[n].scriptPubKey && vout[n].scriptPubKey.hex; + if (hex && scripthashOf(hex) === this.beacon) { touches = true; this.utxos.add(tx.txid + ":" + n); } + } + for (const vin of tx.vin || []) { + if (vin.txid) { const k = vin.txid + ":" + vin.vout; if (this.utxos.has(k)) { touches = true; this.utxos.delete(k); } } + } + if (touches) { this.history.set(tx.txid, height); this.txCache.set(tx.txid, tx); } + return touches; + } + getHistory() { + return [...this.history.entries()] + .map(([tx_hash, height]) => ({ tx_hash, height })) + .sort((a, b) => (a.height - b.height) || (a.tx_hash < b.tx_hash ? -1 : 1)); + } + // Seed directly (used by tests / a proxy backfill). + seed(history, txMap) { + for (const { tx_hash, height } of history) this.history.set(tx_hash, height); + if (txMap) for (const [id, tx] of Object.entries(txMap)) this.txCache.set(id, tx); + } + toJSON() { + return { history: [...this.history], txCache: [...this.txCache], utxos: [...this.utxos] }; + } + static fromJSON(beacon, j) { + const idx = new BeaconIndex(beacon); + if (j) { idx.history = new Map(j.history || []); idx.txCache = new Map(j.txCache || []); idx.utxos = new Set(j.utxos || []); } + return idx; + } +} + +// ---- BCHN block-scan source: fills a BeaconIndex from a full node ---- +export class RpcSource { + constructor(index, cfg, rpc) { + this.index = index; this.cfg = cfg; this.rpc = rpc; + this.lastHeight = -1; this.lastHash = null; + } + load() { + if (existsSync(this.cfg.stateFile)) { + try { + const j = JSON.parse(readFileSync(this.cfg.stateFile, "utf8")); + this.index = BeaconIndex.fromJSON(this.cfg.beacon, j.index); + this.lastHeight = j.lastHeight ?? -1; this.lastHash = j.lastHash ?? null; + log(`loaded state: height ${this.lastHeight}, ${this.index.history.size} beacon txs`); + } catch (e) { log("state load failed, starting fresh:", e.message); } + } + return this.index; + } + save() { + try { + writeFileSync(this.cfg.stateFile, JSON.stringify({ + lastHeight: this.lastHeight, lastHash: this.lastHash, index: this.index.toJSON(), + })); + } catch (e) { log("state save failed:", e.message); } + } + async scanBlock(height) { + const hash = await this.rpc("getblockhash", [height]); + const block = await this.rpc("getblock", [hash, 2]); + for (const tx of block.tx || []) this.index.processTx(tx, height); + this.lastHeight = height; this.lastHash = hash; + return block.tx ? block.tx.length : 0; + } + async catchUp() { + const tip = await this.rpc("getblockcount", []); + // Reorg guard: if the block we last scanned no longer hashes the same, roll back. + if (this.lastHeight >= 0 && this.lastHash) { + try { + const h = await this.rpc("getblockhash", [this.lastHeight]); + if (h !== this.lastHash) { + const rollback = Math.max(this.cfg.startHeight, this.lastHeight - 10); + log(`reorg detected at ${this.lastHeight} — rolling back to ${rollback}`); + for (const [txid, ht] of [...this.index.history]) if (ht >= rollback) { this.index.history.delete(txid); this.index.txCache.delete(txid); } + this.lastHeight = rollback - 1; + } + } catch {} + } + let from = Math.max(this.lastHeight + 1, this.cfg.startHeight); + if (from > tip) return 0; + log(`scanning blocks ${from}..${tip}`); + for (let h = from; h <= tip; h++) { + await this.scanBlock(h); + if (h % 1000 === 0) { this.save(); log(`… ${h}/${tip} (${this.index.history.size} beacon txs)`); } + } + this.save(); + log(`up to date at ${tip} — ${this.index.history.size} beacon txs`); + return tip - from + 1; + } + async scanMempool() { + try { + const ids = await this.rpc("getrawmempool", []); + for (const txid of ids) { + if (this.index.history.has(txid)) continue; + const tx = await this.rpc("getrawtransaction", [txid, true]).catch(() => null); + if (tx) this.index.processTx(tx, 0); + } + } catch {} + } + async follow() { + for (;;) { + try { await this.catchUp(); if (this.cfg.scanMempool) await this.scanMempool(); } + catch (e) { log("catchUp error (will retry):", e.message); } + await new Promise((r) => setTimeout(r, this.cfg.pollMs)); + } + } +} + +// ---- electrum-over-WebSocket server (newline-delimited JSON-RPC) ---- +export function startElectrumWS(index, { bind, port }, rpc) { + const getTx = async (txid, verbose) => { + if (verbose) return index.txCache.get(txid) || (rpc ? await rpc("getrawtransaction", [txid, true]) : null); + return rpc ? await rpc("getrawtransaction", [txid, false]) : null; + }; + async function handle(req) { + const { id, method, params = [] } = req; + try { + if (method === "server.version") return { id, result: ["BNS-Indexer/0.1", "1.4.3"] }; + if (method === "server.ping") return { id, result: null }; + if (method === "server.banner") return { id, result: "BNS-only indexer" }; + if (method === "blockchain.scripthash.get_history") + return { id, result: params[0] === index.beacon ? index.getHistory() : [] }; + if (method === "blockchain.transaction.get") + return { id, result: await getTx(params[0], params[1]) }; + return { id, error: { code: -32601, message: "unsupported method: " + method } }; + } catch (e) { return { id, error: { code: -1, message: String(e && e.message || e) } }; } + } + const wss = new WebSocketServer({ host: bind, port }); + wss.on("connection", (ws) => { + ws.on("message", async (data) => { + for (const line of data.toString().split("\n")) { + const s = line.trim(); if (!s) continue; + let req; try { req = JSON.parse(s); } catch { continue; } + const resp = await handle(req); + if (resp) { try { ws.send(JSON.stringify(resp) + "\n"); } catch {} } + } + }); + ws.on("error", () => {}); + }); + log(`electrum-ws listening on ${bind}:${port}`); + return wss; +} + +// ---- main ---- +async function main() { + const rpc = makeRpc(CONFIG); + const chain = await rpc("getblockchaininfo", []).catch((e) => { throw new Error(`cannot reach BCHN at ${CONFIG.rpcUrl}: ${e.message}`); }); + log(`connected to BCHN: chain=${chain.chain} blocks=${chain.blocks}`); + const src = new RpcSource(new BeaconIndex(CONFIG.beacon), CONFIG, rpc); + src.load(); + startElectrumWS(src.index, { bind: CONFIG.wsBind, port: CONFIG.wsPort }, rpc); + await src.follow(); +} + +// Only run the daemon when invoked directly (imports for tests stay side-effect free). +if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, "/")}`) { + main().catch((e) => { console.error("fatal:", e.message); process.exit(1); }); +} diff --git a/src/lib/name-index.js b/src/lib/name-index.js new file mode 100644 index 0000000..c48a6d2 --- /dev/null +++ b/src/lib/name-index.js @@ -0,0 +1,151 @@ +// 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 }); +} diff --git a/src/lib/portfolio.js b/src/lib/portfolio.js new file mode 100644 index 0000000..0f02a60 --- /dev/null +++ b/src/lib/portfolio.js @@ -0,0 +1,147 @@ +// The operator's name holdings, published and verifiable against the chain. +// +// WHY THIS EXISTS +// The registry operator holding valuable names is normal — ENS withheld short +// names, Handshake reserved top domains. What turns it corrosive is secrecy: a +// registry whose operator is *suspected* of quietly sniping the names users +// search for is finished, and with it every name in the portfolio. The registry's +// credibility is worth more than the portfolio it protects. +// +// So the holdings are declared in a manifest and this module checks that manifest +// against the chain. Two directions, and the second is the one that matters: +// +// declared but not held → the manifest is stale or wrong (embarrassing) +// HELD BUT NOT DECLARED → an undisclosed holding (disqualifying) +// +// A transparency page that only lists what you chose to list proves nothing. This +// one is falsifiable by anyone: the operator's addresses are public, the chain is +// public, so a third party can run the same check and get the same answer. That +// is the difference between a claim and a property. + +import { getUtxosForAddresses } from "./chain.js"; +import { buildIndex, normalizeName } from "./resolver-web.js"; + +/** A declared holding. `acquiredAt`/`paidSats` are operator-supplied context. */ +export const MANIFEST_FIELDS = ["name", "acquiredAt", "paidSats", "askingSats", "note"]; + +export const PORTFOLIO_STATUS = { + ok: "ok", // declared and held + missing: "declared-not-held", // in the manifest, not on-chain + undisclosed: "held-not-declared", // on-chain, not in the manifest +}; + +/** + * Every name currently held by a set of addresses, read from the chain. + * + * Works from the certificate side rather than the manifest side: find token + * UTXOs, then match their category against the registry index. A name whose + * certificate the operator holds is a name the operator controls, whatever any + * manifest says. + */ +export async function heldNames(client, addresses) { + const [utxos, index] = await Promise.all([ + getUtxosForAddresses(client, addresses), + buildIndex({ client }), + ]); + + const byCategory = new Map(); + for (const [name, entry] of index) byCategory.set(entry.category, { name, entry }); + + const held = []; + for (const u of utxos) { + if (!u.token?.nft) continue; + const match = byCategory.get(u.token.category); + if (!match) continue; // a token of ours that is not a BNS name in this registry + held.push({ + name: match.name, + category: u.token.category, + address: u.address, + registeredTxid: match.entry.txid, + registeredHeight: match.entry.height, + records: match.entry.records, + }); + } + return held.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Reconcile a declared manifest against reality. + * + * Deliberately reports BOTH directions and never merges them into a single + * "verified" boolean — the failure modes have completely different meanings and + * flattening them would hide the one that matters. + */ +export async function verifyPortfolio(client, { addresses, manifest }) { + const held = await heldNames(client, addresses); + const heldByName = new Map(held.map((h) => [h.name, h])); + + const declared = manifest.map((m) => ({ ...m, name: canonical(m.name) })); + const declaredNames = new Set(declared.map((d) => d.name)); + + const entries = declared.map((d) => { + const onChain = heldByName.get(d.name); + return { + ...d, + status: onChain ? PORTFOLIO_STATUS.ok : PORTFOLIO_STATUS.missing, + onChain: onChain ?? null, + forSale: typeof d.askingSats === "bigint" ? d.askingSats > 0n : false, + }; + }); + + const undisclosed = held + .filter((h) => !declaredNames.has(h.name)) + .map((h) => ({ ...h, status: PORTFOLIO_STATUS.undisclosed })); + + return { + entries, + undisclosed, + heldCount: held.length, + declaredCount: declared.length, + /** The only claim worth making: nothing held is undeclared. */ + complete: undisclosed.length === 0, + /** Everything declared is genuinely held. Weaker, but embarrassing if false. */ + accurate: entries.every((e) => e.status === PORTFOLIO_STATUS.ok), + checkedAddresses: addresses.length, + }; +} + +function canonical(name) { + const raw = normalizeName(name); + return raw.includes(".") ? raw : `${raw}.bch`; +} + +/** + * Render the manifest for publication. + * + * Refuses to produce a page when holdings are undisclosed. A transparency + * artefact that quietly omits things is worse than none: it converts "we did not + * say" into "we said something untrue", and it is the omission a reader will find + * first because the chain is public. + */ +export function renderPortfolio(report, { network = "chipnet" } = {}) { + if (!report.complete) { + const names = report.undisclosed.map((u) => u.name).join(", "); + const err = new Error( + `refusing to publish: ${report.undisclosed.length} name(s) held but not declared (${names}). ` + + `Add them to the manifest or move them out of the declared addresses.`, + ); + err.code = "UNDISCLOSED_HOLDINGS"; + err.undisclosed = report.undisclosed; + throw err; + } + return { + network, + heldCount: report.heldCount, + complete: true, + names: report.entries.map((e) => ({ + name: e.name, + acquiredAt: e.acquiredAt ?? null, + paidSats: e.paidSats ? String(e.paidSats) : null, + askingSats: e.askingSats ? String(e.askingSats) : null, + forSale: e.forSale, + note: e.note ?? null, + registeredTxid: e.onChain?.registeredTxid ?? null, + verifiable: `any observer can confirm this from the chain`, + })), + }; +} diff --git a/src/lib/registry-lifecycle.js b/src/lib/registry-lifecycle.js new file mode 100644 index 0000000..8e07bee --- /dev/null +++ b/src/lib/registry-lifecycle.js @@ -0,0 +1,231 @@ +// Name lifecycle: phases, prices and the payment split. +// +// WHAT THIS IS FOR +// These are the rules the covenant will encode. Until the covenant exists they +// are still needed, by three callers that must all agree: +// +// · the storefront, to quote a renewal or a takeover +// · the wallet UI, to show "ACTIVE until block X" and warn before expiry +// · the covenant tests, as the reference the contract is checked against +// +// So they live here as pure functions of block height, deliberately written the +// way a covenant computes: integer arithmetic on satoshis and heights, no +// floating point, no clock, no oracle. When the CashScript contract is written it +// must produce identical numbers, and these functions are the fixture. +// +// SEE Decentralized.DNS/DESIGN-covenant-registry.md for the reasoning behind +// every constant here. The short version of the two least obvious ones: +// +// Decay, not a step. The takeover price glides from the IDLE premium down to +// the OPEN floor. A step would put a starting pistol on one block and hand the +// name to whoever paid the biggest miner fee — which is the outcome the pricing +// is trying to prevent. A glide is also demand discovery without an oracle: a +// name people want gets bought on the way down at what someone will actually +// pay, and never reaches the floor. +// +// No miner share by default. Paying miners is only possible as transaction fee, +// and a fee attached to a takeover is a discount for the one party who also +// controls block ordering and can read the demand signal out of the mempool. +// Commit–reveal neutralises that, but then the design's safety depends on +// commit–reveal being right. Folding the share into the fund removes the +// problem structurally. Flip `SPLIT.minerPercent` to 15 to restore it. + +// ---------- time ---------- + +/** ~10-minute blocks. Whole-year registrations only, so this is exact enough. */ +export const BLOCKS_PER_YEAR = 52_560; +export const BLOCKS_PER_MONTH = 4_380; + +/** Grace window after expiry during which the owner may still reclaim. */ +export const GRACE_MONTHS = 6; +export const GRACE_BLOCKS = GRACE_MONTHS * BLOCKS_PER_MONTH; + +export const MIN_REGISTRATION_YEARS = 1; + +// ---------- phases ---------- + +export const PHASE = { + active: "active", // owner holds it, renewal not yet due + idle: "idle", // expired; owner may reclaim cheaply, others may take over dearly + open: "open", // grace elapsed; anyone may buy at the floor +}; + +/** + * Which phase a name is in at a given block height. + * `expiryHeight` and `idleEndHeight` are covenant parameters. + */ +export function phaseAt({ height, expiryHeight, idleEndHeight }) { + if (height < expiryHeight) return PHASE.active; + if (height < idleEndHeight) return PHASE.idle; + return PHASE.open; +} + +export const idleEndFor = (expiryHeight) => expiryHeight + GRACE_BLOCKS; +export const expiryFor = (startHeight, years) => startHeight + BLOCKS_PER_YEAR * years; + +// ---------- tiers ---------- + +// Length-based, because it is the one metric a covenant can be given as a +// parameter and a buyer can predict without consulting us. Dictionary words and +// reserved terms are handled at registration time by assigning a higher tier — +// the contract only ever sees the tier number. +// +// Satoshi figures are chipnet placeholders carrying USD *intent* (see the table +// in the design note). Mainnet needs repricing against a real BCH price, and +// once a name is minted its tier is fixed for that covenant's lifetime — the +// migrate path is the only way to reprice. That is a deliberate trade for having +// no oracle. +export const TIERS = [ + { tier: 1, maxLength: 2, renewalSats: 2_000_000n }, // ~$200/yr intent + { tier: 2, maxLength: 4, renewalSats: 500_000n }, // ~$50/yr + { tier: 3, maxLength: 7, renewalSats: 200_000n }, // ~$20/yr + { tier: 4, maxLength: Infinity, renewalSats: 100_000n }, // ~$10/yr +]; + +/** Multipliers on the annual renewal price. */ +export const IDLE_PREMIUM_MULTIPLE = 50n; // tier 3: $20 -> $1000 +export const OPEN_FLOOR_MULTIPLE = 5n; // tier 3: $20 -> $100 + +/** Tier for a name, by label length. `name` may be "alice" or "alice.nav". */ +export function tierForName(name) { + const label = String(name).split(".")[0]; + if (!label.length) throw new Error("empty name has no tier"); + return TIERS.find((t) => label.length <= t.maxLength).tier; +} + +export function tierConfig(tier) { + const t = TIERS.find((x) => x.tier === tier); + if (!t) throw new Error(`unknown tier ${tier}`); + return t; +} + +export const renewalPriceSats = (tier, years = 1) => + tierConfig(tier).renewalSats * BigInt(years); + +export const idlePremiumSats = (tier) => tierConfig(tier).renewalSats * IDLE_PREMIUM_MULTIPLE; +export const openFloorSats = (tier) => tierConfig(tier).renewalSats * OPEN_FLOOR_MULTIPLE; + +// ---------- takeover price ---------- + +/** + * What a stranger must pay to take the name at this height. + * + * ACTIVE — not for sale at any price; the owner holds it. + * IDLE — glides linearly from the premium down to the floor. + * OPEN — the floor, indefinitely. + * + * Integer arithmetic throughout, in the order a covenant would do it: multiply + * before dividing so the remainder is not lost, and never touch floating point. + */ +export function takeoverPriceSats({ height, expiryHeight, idleEndHeight, tier }) { + const phase = phaseAt({ height, expiryHeight, idleEndHeight }); + if (phase === PHASE.active) return null; // not purchasable + + const floor = openFloorSats(tier); + if (phase === PHASE.open) return floor; + + const premium = idlePremiumSats(tier); + const span = BigInt(idleEndHeight - expiryHeight); + const remaining = BigInt(idleEndHeight - height); // 1..span within IDLE + return floor + ((premium - floor) * remaining) / span; +} + +/** What the owner pays to reclaim or renew — never affected by phase or demand. */ +export function reclaimPriceSats({ tier, years = 1 }) { + return renewalPriceSats(tier, years); +} + +// ---------- the split ---------- + +/** + * Recommended: 30 / 0 / 70 in IDLE, 0 / 0 / 100 in OPEN. + * + * The owner's 30% is a buy-out while they are still entitled to reclaim. Once + * OPEN, that entitlement has lapsed and so does the justification, so the share + * goes to zero rather than paying an absent party in perpetuity. + * + * `minerPercent` is 0 by design — see the header. Set it to 15 to restore the + * miner share; the remainder is taken out of the fund, and the amount is left + * unspent so it becomes transaction fee (the only way to pay miners at all). + */ +export const SPLIT = { + ownerPercentIdle: 30n, + ownerPercentOpen: 0n, + minerPercent: 0n, // 15n restores the miner share +}; + +/** + * Split a takeover payment into the outputs a covenant must require. + * + * `minerFeeSats` is NOT an output — it is the amount left unspent, which the + * miner collects as fee. Callers building the transaction must under-spend by + * exactly this much. + */ +export function splitTakeover({ amountSats, phase }) { + const amount = BigInt(amountSats); + if (amount <= 0n) throw new Error("takeover amount must be positive"); + if (phase === PHASE.active) throw new Error("an ACTIVE name is not for sale"); + + const ownerPercent = phase === PHASE.idle ? SPLIT.ownerPercentIdle : SPLIT.ownerPercentOpen; + const ownerSats = (amount * ownerPercent) / 100n; + const minerFeeSats = (amount * SPLIT.minerPercent) / 100n; + // The fund takes the remainder, so integer division can never lose or + // conjure a satoshi — the three parts always sum to the amount exactly. + const fundSats = amount - ownerSats - minerFeeSats; + return { ownerSats, minerFeeSats, fundSats, totalSats: amount }; +} + +// ---------- commit–reveal ---------- + +// Applies to first registrations AND takeovers. One confirmation is enough to +// make ordering unforgeable: an attacker seeing a reveal cannot produce a commit +// older than one already in a block. The upper bound stops speculative +// pre-commits from being hoarded indefinitely. +export const COMMIT = { + minConfirmations: 1, + maxConfirmations: 1008, // ~1 week + saltBytes: 16, +}; + +export const commitIsMature = (commitHeight, atHeight) => { + const age = atHeight - commitHeight; + return age >= COMMIT.minConfirmations && age <= COMMIT.maxConfirmations; +}; + +// ---------- fund ---------- + +export const FUND = { + // 2-of-3 multisig, published, with visible spending from day one. Baked into + // the covenant at mint and therefore permanent: if this key is lost every + // future takeover pays into a hole, and if it is compromised the attacker + // collects the fund share forever. The migrate path (design note §4.6) is the + // only escape, which is why it must exist from the first version. + address: null, // set before any mainnet covenant is deployed + multisig: "2-of-3", + purpose: "Bitcoin Cash development and infrastructure", + // Voluntary and opt-in — it touches only names in this registry, not the + // chain and not miners' rewards. That distinction is the answer to the + // inevitable comparison with the 2020 IFP, and it only holds if the fund is + // transparent from the start rather than after the first accusation. + voluntary: true, +}; + +/** Everything a UI needs to describe a name's standing at a glance. */ +export function describeLifecycle({ height, expiryHeight, idleEndHeight, tier }) { + const phase = phaseAt({ height, expiryHeight, idleEndHeight }); + return { + phase, + tier, + blocksUntilExpiry: Math.max(0, expiryHeight - height), + blocksUntilOpen: Math.max(0, idleEndHeight - height), + reclaimPriceSats: reclaimPriceSats({ tier }), + takeoverPriceSats: takeoverPriceSats({ height, expiryHeight, idleEndHeight, tier }), + ownerCanReclaim: phase !== PHASE.open, + // The number that actually protects a present owner: their option is tens of + // times cheaper than a stranger's, so renewing always beats being bought out. + ownerAdvantage: + phase === PHASE.idle + ? Number(idlePremiumSats(tier) / reclaimPriceSats({ tier })) + : null, + }; +} diff --git a/src/resolve.js b/src/resolve.js index 2500f50..eced630 100644 --- a/src/resolve.js +++ b/src/resolve.js @@ -12,7 +12,10 @@ if (name) { const index = await buildIndex(wallet.provider); if (index.size === 0) console.log("(no names registered yet)"); for (const e of index.values()) { - console.log(`${e.name}.bch -> ${JSON.stringify(e.records)} [category ${e.category.slice(0, 12)}…]`); + // `e.name` is the on-chain form: a bare label for .bch, "