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:
parent
a45320db7d
commit
f9a5eb367f
9 changed files with 1538 additions and 1 deletions
124
src/indexer/README.md
Normal file
124
src/indexer/README.md
Normal 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
245
src/indexer/bns-indexer.js
Normal 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
151
src/lib/name-index.js
Normal 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
147
src/lib/portfolio.js
Normal 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`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
231
src/lib/registry-lifecycle.js
Normal file
231
src/lib/registry-lifecycle.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
83
test/bns-indexer.test.mjs
Normal 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);
|
||||
292
test/portfolio-index.test.mjs
Normal file
292
test/portfolio-index.test.mjs
Normal 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");
|
||||
});
|
||||
261
test/registry-lifecycle.test.mjs
Normal file
261
test/registry-lifecycle.test.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue