argus/test/electrum-pool.test.mjs
Local Dev a45320db7d Initial commit — Silent Mode baseline (2026-07-29)
Snapshot of the decentralized-web stack at the point of the resolver+Theseus
rebuild deploy. Includes:

- Argus (BNS engine + resolver daemon + Sia gateway)
- AriadneResolver (Windows Inno installer bundle + Android APK sources +
  Firefox extension)
- TheseusNavigator (Electron browser)
- site/ (silentmode.st content, deployed to Sia at bns/silentmode/)
- design docs, roadmap, protocol spec

Secrets excluded via .gitignore: Argus/sia-s3.json, Argus/wallets.json,
Argus/ca/*.key,*.crt. Build outputs, node_modules, and bundled runtimes
also excluded.

Shipped hashes on dl.silentmode.st at this commit:
  AriadneResolver-Setup-0.1.0.exe    5bcb216eef31ea28ed767e4134ab74bd5ac69dfbd365fd249e9e6938e55c986a
  TheseusNavigator-Setup-0.0.1.exe   7c735e88bad2da3347145adba3016c8f626a18b8422289c8c6ba471972e2952b
  TheseusNavigator-0.0.1-portable.exe 008fd84445babeabb401b2bca40ea9466b24b0e6d6c85104da7640c5c5c84521
  ariadne-v0.2.apk                   635c8f04d44ef855a8390b9eeddb8cd2d50622e81cc4e5004e8daffc1bb0425c
2026-07-29 13:54:34 +02:00

213 lines
8.3 KiB
JavaScript

// Multi-server electrum: availability failover and omission resistance.
//
// No network — fake clients only. That is deliberate: the failure this defends
// against (a server quietly hiding a registration) is not one you can provoke
// against real servers on demand, so it has to be testable offline or it will
// never be tested at all.
import test from "node:test";
import assert from "node:assert/strict";
import { ElectrumPool } from "../src/lib/electrum.js";
/** A fake electrum server with a scripted beacon history. */
function fakeServer(url, history, { failOn = [] } = {}) {
return {
url,
calls: [],
async call(method, params) {
this.calls.push(method);
if (failOn.includes(method)) throw new Error(`${url} refused ${method}`);
if (method === "blockchain.scripthash.get_history") return history;
if (method === "server.version") return ["fake", "1.4.2"];
return null;
},
close() { this.closed = true; },
};
}
const tx = (hash, height) => ({ tx_hash: hash, height });
const A = tx("aaaa", 100);
const B = tx("bbbb", 101);
const C = tx("cccc", 0); // unconfirmed
// ---------- availability ----------
test("call() uses the first healthy server", async () => {
const s1 = fakeServer("wss://one", [A]);
const s2 = fakeServer("wss://two", [A]);
const pool = new ElectrumPool([s1, s2]);
await pool.call("server.version");
assert.equal(s1.calls.length, 1);
assert.equal(s2.calls.length, 0, "the second server is not troubled when the first answers");
});
test("call() fails over transparently when a server dies", async () => {
const dead = fakeServer("wss://dead", [], { failOn: ["blockchain.scripthash.get_history"] });
const live = fakeServer("wss://live", [A, B]);
const pool = new ElectrumPool([dead, live]);
const r = await pool.call("blockchain.scripthash.get_history", ["sh"]);
assert.deepEqual(r, [A, B]);
});
test("when every server fails the error names each one and why", async () => {
const pool = new ElectrumPool([
fakeServer("wss://one", [], { failOn: ["x"] }),
fakeServer("wss://two", [], { failOn: ["x"] }),
]);
await assert.rejects(
() => pool.call("x"),
(e) => {
assert.equal(e.code, "NO_ELECTRUM");
assert.equal(e.failures.length, 2);
assert.match(e.message, /wss:\/\/one/);
assert.match(e.message, /wss:\/\/two/);
assert.match(e.message, /non-standard\s+ports/, "tells the operator what to actually check");
return true;
},
);
});
test("callAll reports per-server outcomes without throwing on partial failure", async () => {
const pool = new ElectrumPool([
fakeServer("wss://ok", [A]),
fakeServer("wss://bad", [], { failOn: ["blockchain.scripthash.get_history"] }),
]);
const out = await pool.callAll("blockchain.scripthash.get_history", ["sh"]);
assert.equal(out.length, 2);
assert.equal(out.filter((o) => o.ok).length, 1);
assert.equal(out.find((o) => !o.ok).url, "wss://bad");
});
// ---------- omission resistance (the reason this exists) ----------
test("a server hiding a registration cannot hide it from the pool", async () => {
// s2 omits B — the exact attack: hide a REG so a taken name looks available.
const honest = fakeServer("wss://honest", [A, B]);
const liar = fakeServer("wss://liar", [A]);
const pool = new ElectrumPool([liar, honest]); // liar queried first, deliberately
const { history, disagreements } = await pool.unionHistory("sh");
const hashes = history.map((h) => h.tx_hash).sort();
assert.deepEqual(hashes, ["aaaa", "bbbb"], "the hidden registration is still in the union");
assert.equal(disagreements.length, 1, "the omission is reported, not silently patched over");
assert.equal(disagreements[0].url, "wss://liar");
assert.deepEqual(disagreements[0].missing, ["bbbb"]);
});
test("union takes MORE history, never less — a majority cannot erase a registration", async () => {
// Two servers omit B, one has it. A quorum vote would drop B; union keeps it.
const pool = new ElectrumPool([
fakeServer("wss://a", [A]),
fakeServer("wss://b", [A]),
fakeServer("wss://c", [A, B]),
]);
const { history } = await pool.unionHistory("sh");
assert.ok(
history.some((h) => h.tx_hash === "bbbb"),
"outvoted is not the same as untrue — two stale servers must not erase a real registration",
);
});
test("a confirmed height wins over the same tx reported as unconfirmed", async () => {
const pool = new ElectrumPool([
fakeServer("wss://behind", [tx("cccc", 0)]),
fakeServer("wss://ahead", [tx("cccc", 500)]),
]);
const { history } = await pool.unionHistory("sh");
assert.equal(history.find((h) => h.tx_hash === "cccc").height, 500);
});
test("the lowest confirmed height wins when servers disagree about a block", async () => {
const pool = new ElectrumPool([
fakeServer("wss://a", [tx("dddd", 900)]),
fakeServer("wss://b", [tx("dddd", 800)]),
]);
const { history } = await pool.unionHistory("sh");
assert.equal(history.find((h) => h.tx_hash === "dddd").height, 800);
});
test("identical servers produce no disagreement noise", async () => {
const pool = new ElectrumPool([
fakeServer("wss://a", [A, B, C]),
fakeServer("wss://b", [A, B, C]),
]);
const { disagreements, servers } = await pool.unionHistory("sh");
assert.equal(servers, 2);
assert.equal(disagreements.length, 0);
});
test("unreachable servers are surfaced, and the rest still answer", async () => {
const pool = new ElectrumPool([
fakeServer("wss://down", [], { failOn: ["blockchain.scripthash.get_history"] }),
fakeServer("wss://up", [A, B]),
]);
const { history, servers, unreachable } = await pool.unionHistory("sh");
assert.equal(servers, 1);
assert.equal(unreachable.length, 1);
assert.equal(unreachable[0].url, "wss://down");
assert.equal(history.length, 2, "a degraded pool still resolves");
});
test("unionHistory refuses to answer from nothing", async () => {
const pool = new ElectrumPool([
fakeServer("wss://a", [], { failOn: ["blockchain.scripthash.get_history"] }),
]);
await assert.rejects(() => pool.unionHistory("sh"), (e) => e.code === "NO_ELECTRUM");
});
test("an empty pool is refused at construction", () => {
assert.throws(() => new ElectrumPool([]), /at least one client/);
});
test("close() closes every connection", () => {
const a = fakeServer("wss://a", []);
const b = fakeServer("wss://b", []);
new ElectrumPool([a, b]).close();
assert.equal(a.closed, true);
assert.equal(b.closed, true);
});
// ---------- the resolver uses the union ----------
test("buildIndex takes the union when handed a pool", async () => {
const { buildIndex, BEACON_SCRIPTHASH } = await import("../src/lib/resolver-web.js");
// Neither server can produce a valid registration from these stubs, so the
// index is empty either way — what matters is that the union path ran and the
// omission was recorded where a caller can see it.
const honest = fakeServer("wss://honest", [A, B]);
const liar = fakeServer("wss://liar", [A]);
honest.call = async function (m, p) {
if (m === "blockchain.scripthash.get_history") return [A, B];
if (m === "blockchain.transaction.get") return { vout: [], vin: [] };
return null;
};
liar.call = async function (m, p) {
if (m === "blockchain.scripthash.get_history") return [A];
if (m === "blockchain.transaction.get") return { vout: [], vin: [] };
return null;
};
const pool = new ElectrumPool([liar, honest]);
const names = await buildIndex({ client: pool });
assert.ok(names instanceof Map);
assert.ok(names.integrity, "integrity metadata is attached for callers that care");
assert.equal(names.integrity.servers, 2);
assert.equal(names.integrity.disagreements.length, 1);
assert.equal(names.integrity.disagreements[0].url, "wss://liar");
assert.equal(BEACON_SCRIPTHASH.length, 64);
});
test("buildIndex still works with a single plain client (no pool)", async () => {
const { buildIndex } = await import("../src/lib/resolver-web.js");
const solo = fakeServer("wss://solo", [A]);
solo.call = async function (m) {
if (m === "blockchain.scripthash.get_history") return [A];
if (m === "blockchain.transaction.get") return { vout: [], vin: [] };
return null;
};
const names = await buildIndex({ client: solo });
assert.ok(names instanceof Map);
assert.equal(names.integrity, undefined, "no pool, no cross-check claim");
});