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