293 lines
11 KiB
JavaScript
293 lines
11 KiB
JavaScript
|
|
// 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");
|
||
|
|
});
|