Resolvers took the last message in a transaction. Wherever one party fixes a message and another assembles the transaction - the delivery escrow pins the registration at output 1 - a later output could replace it and every resolver would read that one. Counting instead of choosing removes the question: one message, or the transaction says nothing. Same rule in all three resolvers. The Android parser now recognises the same set of operations as the JavaScript one, since what counts as a message decides which transactions are void. No transaction on chipnet carried more than one. Three older suites built registrations that never created their certificate's category; their fixtures now do, as the sole-certificate rule requires.
289 lines
11 KiB
JavaScript
289 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";
|
|
import { binToHex, sha256 } from "@bitauth/libauth";
|
|
|
|
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: [{ txid: category, vout: 0 }], // the certificate's category is created here (sole-certificate rule)
|
|
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
|
|
const _sha = { binToHex, sha256 };
|
|
function shOf(address) {
|
|
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");
|
|
});
|