214 lines
8.3 KiB
JavaScript
214 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");
|
||
|
|
});
|