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.
This commit is contained in:
Local Dev 2026-07-30 08:16:55 +02:00
parent a45320db7d
commit f9a5eb367f
9 changed files with 1538 additions and 1 deletions

124
src/indexer/README.md Normal file
View file

@ -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.

245
src/indexer/bns-indexer.js Normal file
View file

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

151
src/lib/name-index.js Normal file
View file

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

147
src/lib/portfolio.js Normal file
View file

@ -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`,
})),
};
}

View file

@ -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,
};
}

View file

@ -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, "<label>.<tld>" for
// anything else. Appending ".bch" unconditionally printed "demo.p2p.bch".
const display = e.name.includes(".") ? e.name : `${e.name}.bch`;
console.log(`${display} -> ${JSON.stringify(e.records)} [category ${e.category.slice(0, 12)}…]`);
}
}
process.exit(0);

83
test/bns-indexer.test.mjs Normal file
View file

@ -0,0 +1,83 @@
// Proves the BNS-only indexer serves the electrum protocol correctly, using REAL
// chipnet data (no BCHN needed): pull the beacon history + txs from a public
// electrum, seed the indexer, serve it locally, and confirm resolver-web resolves
// a name through OUR indexer identically to the public server. Also unit-tests
// scripthashOf + processTx against a real beacon transaction.
import WebSocket from "ws";
import { resolveName, BEACON_SCRIPTHASH } from "../src/lib/resolver-web.js";
import { BeaconIndex, RpcSource, startElectrumWS, scripthashOf } from "../src/indexer/bns-indexer.js";
// Dial by IP + SNI (this machine's resolver daemon breaks Node getaddrinfo).
const PUBLIC = { url: "wss://chipnet.bch.ninja:50004", ip: "135.148.236.244" };
const PUBLIC_IPURL = "wss://135.148.236.244:50004";
const PUBLIC_SNI = "chipnet.bch.ninja";
const NAME = "hello.bch";
const PORT = 50099;
let failures = 0;
const ok = (c, m) => { console.log((c ? " ✅ " : " ❌ ") + m); if (!c) failures++; };
// tiny electrum client
function elClient(url, opts) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, opts);
let id = 0, buf = ""; const pending = new Map();
ws.on("open", () => resolve({
call(method, params = []) {
return new Promise((res, rej) => {
const i = ++id; pending.set(i, { res, rej });
ws.send(JSON.stringify({ id: i, method, params }) + "\n");
setTimeout(() => { if (pending.has(i)) { pending.delete(i); rej(new Error("timeout " + method)); } }, 20000);
});
},
close() { ws.close(); },
}));
ws.on("error", reject);
const settle = (line) => { try { const m = JSON.parse(line); const p = pending.get(m.id); if (p) { pending.delete(m.id); m.error ? p.rej(new Error(JSON.stringify(m.error))) : p.res(m.result); } } catch {} };
ws.on("message", (d) => {
buf += d.toString(); let nl;
while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (line) settle(line); }
const rest = buf.trim(); // some servers omit the trailing newline
if (rest) { try { JSON.parse(rest); settle(rest); buf = ""; } catch {} }
});
});
}
console.log("1) pulling REAL beacon data from", PUBLIC.url, "(via IP", PUBLIC.ip + ") …");
const pub = await elClient(PUBLIC_IPURL, { servername: PUBLIC_SNI });
const history = await pub.call("blockchain.scripthash.get_history", [BEACON_SCRIPTHASH]);
ok(Array.isArray(history) && history.length > 0, `beacon history fetched (${history.length} txs)`);
const txMap = {};
for (const { tx_hash } of history) txMap[tx_hash] = await pub.call("blockchain.transaction.get", [tx_hash, true]);
ok(Object.keys(txMap).length === history.length, `fetched all ${history.length} verbose txs`);
// resolve the reference answer via the PUBLIC server (directIP → uses pinned ip)
const reference = await resolveName(NAME, { WebSocket, electrum: [PUBLIC], directIP: true });
pub.close();
ok(!!reference, `${NAME} resolves on the public server (records: ${reference ? Object.keys(reference.records) : "—"})`);
console.log("2) unit-testing scripthashOf + processTx on a real beacon tx …");
// find a real output that pays the beacon and check our scripthash math
let matched = null;
for (const tx of Object.values(txMap)) {
for (const v of tx.vout || []) {
if (v.scriptPubKey && v.scriptPubKey.hex && scripthashOf(v.scriptPubKey.hex) === BEACON_SCRIPTHASH) { matched = tx; break; }
}
if (matched) break;
}
ok(!!matched, "scripthashOf() reproduces the beacon scripthash from a real output");
const probe = new BeaconIndex(BEACON_SCRIPTHASH);
ok(matched && probe.processTx(matched, 100) === true, "processTx() flags a real beacon tx as touching");
console.log("3) serving the SAME data through our indexer and resolving through it …");
const index = new BeaconIndex(BEACON_SCRIPTHASH);
index.seed(history, txMap);
const wss = startElectrumWS(index, { bind: "127.0.0.1", port: PORT }, null); // rpc=null: cache-only
const mine = await resolveName(NAME, { WebSocket, electrum: [{ url: `ws://127.0.0.1:${PORT}` }] });
ok(!!mine, `${NAME} resolves through OUR indexer`);
ok(mine && reference && JSON.stringify(mine.records) === JSON.stringify(reference.records),
"records IDENTICAL to the public server");
ok(mine && reference && mine.category === reference.category, "token category matches");
wss.close();
console.log(failures === 0 ? "\n🎉 ALL PASSED — indexer is protocol-correct" : `\n💥 ${failures} check(s) failed`);
process.exit(failures === 0 ? 0 : 1);

View file

@ -0,0 +1,292 @@
// The two integrity properties: search reveals nothing, and holdings cannot hide.
//
// Offline — fake clients only. Both properties are about what the software makes
// impossible, so they have to be provable without a network.
import test from "node:test";
import assert from "node:assert/strict";
import { NameIndex, buildFromCache } from "../src/lib/name-index.js";
import { verifyPortfolio, renderPortfolio, heldNames, PORTFOLIO_STATUS } from "../src/lib/portfolio.js";
import { addressToLockingBytecode } from "../src/lib/chain.js";
const OPERATOR = "bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu";
const OTHER = "bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs";
const utf8hex = (s) => [...new TextEncoder().encode(s)].map((b) => b.toString(16).padStart(2, "0")).join("");
/** A REG transaction the resolver will accept. */
function regTx(txid, name, category) {
const payload = "BNS1" + JSON.stringify({ o: "REG", n: name, r: {} });
const hex = [...new TextEncoder().encode(payload)].map((b) => b.toString(16).padStart(2, "0")).join("");
const len = (hex.length / 2).toString(16).padStart(2, "0");
return {
txid,
vin: [],
vout: [
{ n: 0, scriptPubKey: { hex: `6a4c${len}${hex}` } },
{ n: 1, scriptPubKey: { hex: "76a914" + "11".repeat(20) + "88ac" },
tokenData: { category, amount: "0", nft: { capability: "none", commitment: utf8hex(name) } } },
],
};
}
/** Electrum stand-in: scripted history, transactions and UTXOs. */
function fakeClient({ history = [], txs = {}, utxos = {} } = {}) {
const calls = [];
return {
calls,
async call(method, params) {
calls.push({ method, params });
if (method === "blockchain.scripthash.get_history") return history;
if (method === "blockchain.transaction.get") {
const tx = txs[params[0]];
if (!tx) throw new Error("unknown tx");
return tx;
}
if (method === "blockchain.scripthash.listunspent") {
// Map scripthash back to whichever address we were given UTXOs for.
for (const [addr, list] of Object.entries(utxos)) {
const sh = shOf(addr);
if (sh === params[0]) return list;
}
return [];
}
if (method === "server.version") return ["fake", "1.4.2"];
return null;
},
close() {},
};
}
// scripthash helper mirroring chain.js
let _sha;
function shOf(address) {
if (!_sha) {
// lazily to keep the import list short
_sha = require("@bitauth/libauth");
}
const bytes = addressToLockingBytecode(address);
return _sha.binToHex(_sha.sha256.hash(bytes).slice().reverse());
}
const CAT_A = "aa".repeat(32);
const CAT_B = "bb".repeat(32);
const TX_A = "1a".repeat(32);
const TX_B = "1b".repeat(32);
// ---------- search reveals nothing ----------
test("after one sync, lookups touch the network zero times", async () => {
const client = fakeClient({
history: [{ tx_hash: TX_A, height: 100 }],
txs: { [TX_A]: regTx(TX_A, "alice", CAT_A) },
});
const idx = new NameIndex();
await idx.sync(client);
const afterSync = client.calls.length;
// The property: searching cannot be observed, because there is nothing to observe.
for (const n of ["alice.bch", "bob.bch", "verysecretplan.bch", "acquisition-target.bch"]) {
idx.lookup(n);
}
assert.equal(client.calls.length, afterSync, "a lookup must not produce a single request");
assert.equal(idx.lookup("alice.bch")?.name, "alice.bch");
assert.equal(idx.lookup("bob.bch"), null);
});
test("lookup before sync fails loudly rather than silently answering wrong", async () => {
const idx = new NameIndex();
assert.throws(() => idx.lookup("alice.bch"), /call sync\(\) before lookup\(\)/);
});
test("a second sync fetches only what is new", async () => {
const txs = { [TX_A]: regTx(TX_A, "alice", CAT_A) };
const client = fakeClient({ history: [{ tx_hash: TX_A, height: 100 }], txs });
const idx = new NameIndex();
const first = await idx.sync(client);
assert.equal(first.fetched, 1);
// A new registration appears.
txs[TX_B] = regTx(TX_B, "bob", CAT_B);
const client2 = fakeClient({
history: [{ tx_hash: TX_A, height: 100 }, { tx_hash: TX_B, height: 101 }],
txs,
});
const second = await idx.sync(client2);
assert.equal(second.fetched, 1, "only the unseen transaction is fetched");
assert.equal(second.cached, 1, "the already-known one is reused");
assert.equal(idx.lookup("bob.bch")?.name, "bob.bch");
assert.equal(idx.lookup("alice.bch")?.name, "alice.bch", "the old entry survives");
});
test("the cache survives a reload through storage", async () => {
const store = new Map();
const storage = {
getItem: (k) => store.get(k) ?? null,
setItem: (k, v) => store.set(k, v),
removeItem: (k) => store.delete(k),
};
const client = fakeClient({
history: [{ tx_hash: TX_A, height: 100 }],
txs: { [TX_A]: regTx(TX_A, "alice", CAT_A) },
});
const a = new NameIndex({ storage });
await a.sync(client);
const b = new NameIndex({ storage });
assert.equal(b.load(), true, "a fresh instance picks up the cache");
const fresh = fakeClient({
history: [{ tx_hash: TX_A, height: 100 }],
txs: { [TX_A]: regTx(TX_A, "alice", CAT_A) },
});
const r = await b.sync(fresh);
assert.equal(r.fetched, 0, "nothing refetched after a reload");
assert.equal(b.lookup("alice.bch")?.name, "alice.bch");
});
test("a shrinking history drops the cache instead of merging disagreeing views", async () => {
const idx = new NameIndex();
await idx.sync(fakeClient({
history: [{ tx_hash: TX_A, height: 100 }, { tx_hash: TX_B, height: 101 }],
txs: { [TX_A]: regTx(TX_A, "alice", CAT_A), [TX_B]: regTx(TX_B, "bob", CAT_B) },
}));
assert.equal(idx.size, 2);
// A reorg, or a server with less data. Refetch rather than trust a merge.
const shrunk = fakeClient({
history: [{ tx_hash: TX_A, height: 100 }],
txs: { [TX_A]: regTx(TX_A, "alice", CAT_A) },
});
const r = await idx.sync(shrunk);
assert.equal(r.fetched, 1, "everything is refetched");
assert.equal(idx.size, 1);
});
test("a pool's union history is preferred over a single server's view", async () => {
// The pool defends against a server hiding a registration; the index must use
// it when available rather than silently taking one server's word.
let unionUsed = false;
const pool = {
async unionHistory() { unionUsed = true; return { history: [{ tx_hash: TX_A, height: 100 }] }; },
async call(method, params) {
if (method === "blockchain.transaction.get") return regTx(TX_A, "alice", CAT_A);
return null;
},
};
const idx = new NameIndex();
await idx.sync(pool);
assert.equal(unionUsed, true, "unionHistory must be used when the client offers it");
assert.equal(idx.lookup("alice.bch")?.name, "alice.bch");
});
test("index rules are not reimplemented — buildFromCache defers to the resolver", async () => {
const history = [{ tx_hash: TX_A, height: 100 }];
const txs = new Map([[TX_A, regTx(TX_A, "alice", CAT_A)]]);
const built = await buildFromCache(history, txs);
assert.ok(built instanceof Map);
assert.equal(built.get("alice.bch")?.category, CAT_A);
});
// ---------- holdings cannot hide ----------
const tokenUtxo = (txid, category, name) => ({
tx_hash: txid, tx_pos: 1, value: 1000, height: 100,
token_data: { category, amount: "0", nft: { capability: "none", commitment: utf8hex(name) } },
});
function portfolioClient({ regs, utxosByAddress }) {
const history = regs.map((r) => ({ tx_hash: r.txid, height: r.height ?? 100 }));
const txs = Object.fromEntries(regs.map((r) => [r.txid, regTx(r.txid, r.name, r.category)]));
return fakeClient({ history, txs, utxos: utxosByAddress });
}
test("declared and held reconciles cleanly", async () => {
const client = portfolioClient({
regs: [{ txid: TX_A, name: "alice", category: CAT_A }],
utxosByAddress: { [OPERATOR]: [tokenUtxo(TX_A, CAT_A, "alice")] },
});
const report = await verifyPortfolio(client, {
addresses: [OPERATOR],
manifest: [{ name: "alice.bch", acquiredAt: "2026-07-01", askingSats: 5_000_000n }],
});
assert.equal(report.complete, true);
assert.equal(report.accurate, true);
assert.equal(report.entries[0].status, PORTFOLIO_STATUS.ok);
assert.equal(report.entries[0].forSale, true);
});
test("a name held but NOT declared is reported — the disqualifying case", async () => {
const client = portfolioClient({
regs: [
{ txid: TX_A, name: "alice", category: CAT_A },
{ txid: TX_B, name: "bob", category: CAT_B },
],
utxosByAddress: {
[OPERATOR]: [tokenUtxo(TX_A, CAT_A, "alice"), tokenUtxo(TX_B, CAT_B, "bob")],
},
});
const report = await verifyPortfolio(client, {
addresses: [OPERATOR],
manifest: [{ name: "alice.bch" }], // bob quietly omitted
});
assert.equal(report.complete, false, "an undeclared holding must not pass");
assert.equal(report.undisclosed.length, 1);
assert.equal(report.undisclosed[0].name, "bob.bch");
assert.equal(report.undisclosed[0].status, PORTFOLIO_STATUS.undisclosed);
});
test("publishing is refused while a holding is undeclared", async () => {
const client = portfolioClient({
regs: [
{ txid: TX_A, name: "alice", category: CAT_A },
{ txid: TX_B, name: "bob", category: CAT_B },
],
utxosByAddress: {
[OPERATOR]: [tokenUtxo(TX_A, CAT_A, "alice"), tokenUtxo(TX_B, CAT_B, "bob")],
},
});
const report = await verifyPortfolio(client, { addresses: [OPERATOR], manifest: [{ name: "alice.bch" }] });
assert.throws(
() => renderPortfolio(report),
(e) => e.code === "UNDISCLOSED_HOLDINGS" && /bob\.bch/.test(e.message),
"a page that silently omits a holding is worse than no page",
);
});
test("declared but not held is flagged separately from undisclosed", async () => {
const client = portfolioClient({
regs: [{ txid: TX_A, name: "alice", category: CAT_A }],
utxosByAddress: { [OPERATOR]: [] }, // sold, or never held
});
const report = await verifyPortfolio(client, { addresses: [OPERATOR], manifest: [{ name: "alice.bch" }] });
assert.equal(report.accurate, false, "a stale manifest entry is not accurate");
assert.equal(report.complete, true, "but nothing is hidden, which is the stronger property");
assert.equal(report.entries[0].status, PORTFOLIO_STATUS.missing);
// Stale is embarrassing, not disqualifying — publishing is still allowed.
assert.doesNotThrow(() => renderPortfolio(report));
});
test("someone else's names are not counted as ours", async () => {
const client = portfolioClient({
regs: [{ txid: TX_B, name: "bob", category: CAT_B }],
utxosByAddress: { [OTHER]: [tokenUtxo(TX_B, CAT_B, "bob")] },
});
const held = await heldNames(client, [OPERATOR]);
assert.deepEqual(held, [], "only the addresses we were asked about count");
});
test("the published form carries the txid so a reader can check it themselves", async () => {
const client = portfolioClient({
regs: [{ txid: TX_A, name: "alice", category: CAT_A }],
utxosByAddress: { [OPERATOR]: [tokenUtxo(TX_A, CAT_A, "alice")] },
});
const report = await verifyPortfolio(client, {
addresses: [OPERATOR],
manifest: [{ name: "alice.bch", acquiredAt: "2026-07-01", paidSats: 13_000n }],
});
const page = renderPortfolio(report, { network: "chipnet" });
assert.equal(page.complete, true);
assert.equal(page.names[0].registeredTxid, TX_A, "verifiable, not merely asserted");
assert.equal(page.names[0].paidSats, "13000");
assert.equal(page.network, "chipnet");
});

View file

@ -0,0 +1,261 @@
// Lifecycle, pricing and the payment split.
//
// These are the rules the covenant will encode, so this file doubles as the
// fixture the CashScript contract must reproduce exactly. Several tests assert
// economic properties rather than arithmetic — no surplus cliff, the owner always
// holds the cheap option, the split never loses a satoshi — because those are the
// ones that quietly stop being true when a constant gets edited.
import test from "node:test";
import assert from "node:assert/strict";
import {
PHASE, phaseAt, expiryFor, idleEndFor,
BLOCKS_PER_YEAR, GRACE_BLOCKS, GRACE_MONTHS,
TIERS, tierForName, tierConfig,
renewalPriceSats, idlePremiumSats, openFloorSats,
takeoverPriceSats, reclaimPriceSats,
SPLIT, splitTakeover,
COMMIT, commitIsMature,
FUND, describeLifecycle,
IDLE_PREMIUM_MULTIPLE, OPEN_FLOOR_MULTIPLE,
} from "../src/lib/registry-lifecycle.js";
const START = 900_000;
const EXPIRY = expiryFor(START, 1);
const IDLE_END = idleEndFor(EXPIRY);
const TIER = 3; // the tier the design's $20 / $1000 / $100 figures describe
// ---------- phases ----------
test("phase boundaries are exact, not approximate", () => {
assert.equal(phaseAt({ height: EXPIRY - 1, expiryHeight: EXPIRY, idleEndHeight: IDLE_END }), PHASE.active);
assert.equal(phaseAt({ height: EXPIRY, expiryHeight: EXPIRY, idleEndHeight: IDLE_END }), PHASE.idle);
assert.equal(phaseAt({ height: IDLE_END - 1, expiryHeight: EXPIRY, idleEndHeight: IDLE_END }), PHASE.idle);
assert.equal(phaseAt({ height: IDLE_END, expiryHeight: EXPIRY, idleEndHeight: IDLE_END }), PHASE.open);
});
test("grace is 6 months, not 12 — the namespace has to actually recycle", () => {
assert.equal(GRACE_MONTHS, 6);
assert.equal(IDLE_END - EXPIRY, GRACE_BLOCKS);
// A 1-year name is unavailable for 18 months after last use, not 24.
assert.equal(IDLE_END - START, BLOCKS_PER_YEAR + GRACE_BLOCKS);
assert.ok(IDLE_END - START < BLOCKS_PER_YEAR * 2);
});
// ---------- tiers ----------
test("tier follows label length and ignores the TLD", () => {
assert.equal(tierForName("ab"), 1);
assert.equal(tierForName("abcd"), 2);
assert.equal(tierForName("abcdefg"), 3);
assert.equal(tierForName("abcdefgh"), 4);
assert.equal(tierForName("sirius.nav"), 3, "the TLD must not shift the tier");
assert.equal(tierForName("sirius"), tierForName("sirius.nav"));
});
test("tier 3 lands on the design's $20 / $1000 / $100 figures", () => {
assert.equal(renewalPriceSats(TIER), 200_000n);
assert.equal(idlePremiumSats(TIER), 10_000_000n); // 50x
assert.equal(openFloorSats(TIER), 1_000_000n); // 5x
assert.equal(IDLE_PREMIUM_MULTIPLE, 50n);
assert.equal(OPEN_FLOOR_MULTIPLE, 5n);
});
test("shorter names cost more, monotonically", () => {
const prices = TIERS.map((t) => t.renewalSats);
for (let i = 1; i < prices.length; i++) {
assert.ok(prices[i] < prices[i - 1], `tier ${i + 1} must be cheaper than tier ${i}`);
}
});
test("multi-year renewal is a straight multiple", () => {
assert.equal(renewalPriceSats(TIER, 3), renewalPriceSats(TIER) * 3n);
});
// ---------- the decay curve ----------
const priceAt = (height) => takeoverPriceSats({ height, expiryHeight: EXPIRY, idleEndHeight: IDLE_END, tier: TIER });
test("an ACTIVE name is not for sale at any price", () => {
assert.equal(priceAt(EXPIRY - 1), null);
assert.equal(priceAt(START), null);
});
test("takeover starts at the premium and ends at the floor", () => {
assert.equal(priceAt(EXPIRY), idlePremiumSats(TIER));
assert.equal(priceAt(IDLE_END), openFloorSats(TIER));
assert.equal(priceAt(IDLE_END + 100_000), openFloorSats(TIER), "the floor holds indefinitely");
});
test("the price only ever falls — never rises, never plateaus mid-glide", () => {
let previous = priceAt(EXPIRY);
for (let h = EXPIRY + 100; h < IDLE_END; h += 500) {
const p = priceAt(h);
assert.ok(p < previous, `price rose or stalled at height ${h}`);
previous = p;
}
});
test("NO SURPLUS CLIFF at the IDLE→OPEN boundary", () => {
// The whole reason for a glide instead of a step. If the price fell sharply at
// one block, every bidder would submit in that block and the winner would be
// whoever paid the biggest miner fee — the outcome the pricing exists to avoid.
const justBefore = priceAt(IDLE_END - 1);
const atBoundary = priceAt(IDLE_END);
const gap = justBefore - atBoundary;
const floor = openFloorSats(TIER);
assert.ok(gap >= 0n);
assert.ok(
gap * 1000n < floor,
`boundary drop of ${gap} sat against a ${floor} sat floor is a cliff, not a glide`,
);
});
test("the glide is roughly linear across the window", () => {
const mid = priceAt(EXPIRY + Number(GRACE_BLOCKS) / 2);
const expected = (idlePremiumSats(TIER) + openFloorSats(TIER)) / 2n;
const drift = mid > expected ? mid - expected : expected - mid;
assert.ok(drift * 100n < expected, "midpoint should sit near the average of the endpoints");
});
test("price is never below the floor at any height", () => {
const floor = openFloorSats(TIER);
for (let h = EXPIRY; h < IDLE_END + 10_000; h += 977) {
assert.ok(priceAt(h) >= floor, `price dipped under the floor at ${h}`);
}
});
// ---------- the owner's advantage ----------
test("the owner's option is tens of times cheaper throughout IDLE", () => {
// This asymmetry — not the length of the grace window — is what protects a
// present owner. Renewing always beats being bought out.
const reclaim = reclaimPriceSats({ tier: TIER });
for (const h of [EXPIRY, EXPIRY + 1000, IDLE_END - 1]) {
assert.ok(priceAt(h) > reclaim * 4n, `takeover at ${h} is not meaningfully dearer than reclaiming`);
}
assert.equal(idlePremiumSats(TIER) / reclaim, 50n, "50x at the start of IDLE");
});
test("reclaim price ignores phase and demand entirely", () => {
// An owner must never be priceable out of a name they already hold.
assert.equal(reclaimPriceSats({ tier: TIER }), renewalPriceSats(TIER));
assert.equal(reclaimPriceSats({ tier: TIER, years: 2 }), renewalPriceSats(TIER, 2));
});
// ---------- the split ----------
test("IDLE takeover splits 30 / 0 / 70", () => {
const s = splitTakeover({ amountSats: 10_000_000n, phase: PHASE.idle });
assert.equal(s.ownerSats, 3_000_000n);
assert.equal(s.minerFeeSats, 0n);
assert.equal(s.fundSats, 7_000_000n);
});
test("OPEN takeover pays the absent owner nothing", () => {
// The 30% is a buy-out while they are still entitled to reclaim. Once OPEN,
// that entitlement has lapsed and so has the justification.
const s = splitTakeover({ amountSats: 1_000_000n, phase: PHASE.open });
assert.equal(s.ownerSats, 0n);
assert.equal(s.fundSats, 1_000_000n);
});
test("the split never loses or conjures a satoshi, at any amount", () => {
// Integer division truncates; the fund takes the remainder so the parts always
// reconcile. A covenant that is one satoshi off simply will not unlock.
for (const amount of [1n, 3n, 7n, 99n, 101n, 999_999n, 10_000_001n, 123_456_789n]) {
for (const phase of [PHASE.idle, PHASE.open]) {
const s = splitTakeover({ amountSats: amount, phase });
assert.equal(
s.ownerSats + s.minerFeeSats + s.fundSats, amount,
`${amount} sat in ${phase} did not reconcile`,
);
assert.ok(s.ownerSats >= 0n && s.fundSats >= 0n && s.minerFeeSats >= 0n);
}
}
});
test("no miner share by default", () => {
assert.equal(SPLIT.minerPercent, 0n, "15% is a discount for the one party that also orders blocks");
});
test("restoring the miner share still reconciles exactly", () => {
// Proving the 30/15/55 variant is a one-value change, not a rewrite.
const original = SPLIT.minerPercent;
try {
SPLIT.minerPercent = 15n;
const s = splitTakeover({ amountSats: 10_000_000n, phase: PHASE.idle });
assert.equal(s.ownerSats, 3_000_000n);
assert.equal(s.minerFeeSats, 1_500_000n);
assert.equal(s.fundSats, 5_500_000n);
assert.equal(s.ownerSats + s.minerFeeSats + s.fundSats, 10_000_000n);
} finally {
SPLIT.minerPercent = original;
}
});
test("selling an ACTIVE name is refused outright", () => {
assert.throws(() => splitTakeover({ amountSats: 1000n, phase: PHASE.active }), /not for sale/);
});
test("a zero or negative takeover is refused", () => {
assert.throws(() => splitTakeover({ amountSats: 0n, phase: PHASE.idle }), /must be positive/);
});
// ---------- commit–reveal ----------
test("commit maturity window is one confirmation to one week", () => {
assert.equal(COMMIT.minConfirmations, 1);
assert.equal(COMMIT.maxConfirmations, 1008);
assert.equal(COMMIT.saltBytes, 16);
assert.equal(commitIsMature(1000, 1000), false, "same block is not a confirmation");
assert.equal(commitIsMature(1000, 1001), true, "one confirmation is enough to fix ordering");
assert.equal(commitIsMature(1000, 2008), true);
assert.equal(commitIsMature(1000, 2009), false, "stale commits must not be hoardable");
});
// ---------- fund ----------
test("the fund is unset until deliberately configured", () => {
// A covenant bakes this in permanently. Shipping a placeholder would send
// every takeover to whatever address happened to be in the file.
assert.equal(FUND.address, null);
assert.equal(FUND.multisig, "2-of-3");
assert.equal(FUND.voluntary, true, "the answer to the IFP comparison rests on this");
});
// ---------- the UI summary ----------
test("describeLifecycle gives a UI everything it needs to warn in time", () => {
const active = describeLifecycle({ height: EXPIRY - 5000, expiryHeight: EXPIRY, idleEndHeight: IDLE_END, tier: TIER });
assert.equal(active.phase, PHASE.active);
assert.equal(active.blocksUntilExpiry, 5000);
assert.equal(active.takeoverPriceSats, null);
assert.equal(active.ownerCanReclaim, true);
const idle = describeLifecycle({ height: EXPIRY + 10, expiryHeight: EXPIRY, idleEndHeight: IDLE_END, tier: TIER });
assert.equal(idle.phase, PHASE.idle);
assert.equal(idle.blocksUntilExpiry, 0);
assert.ok(idle.blocksUntilOpen > 0);
assert.ok(idle.ownerAdvantage >= 40, `owner advantage of ${idle.ownerAdvantage}x is too thin`);
const open = describeLifecycle({ height: IDLE_END + 1, expiryHeight: EXPIRY, idleEndHeight: IDLE_END, tier: TIER });
assert.equal(open.phase, PHASE.open);
assert.equal(open.ownerCanReclaim, false);
assert.equal(open.takeoverPriceSats, openFloorSats(TIER));
});
test("every tier survives a full lifecycle without a cliff or a rise", () => {
for (const { tier } of TIERS) {
const floor = openFloorSats(tier);
let previous = takeoverPriceSats({ height: EXPIRY, expiryHeight: EXPIRY, idleEndHeight: IDLE_END, tier });
assert.equal(previous, idlePremiumSats(tier));
for (let h = EXPIRY + 1000; h <= IDLE_END; h += 2000) {
const p = takeoverPriceSats({ height: h, expiryHeight: EXPIRY, idleEndHeight: IDLE_END, tier });
assert.ok(p <= previous, `tier ${tier} price rose at ${h}`);
assert.ok(p >= floor, `tier ${tier} dipped below floor at ${h}`);
previous = p;
}
}
});