igneum/tools/finality-attacks/lib/rpc.mjs
igneum-josh d43b2b473e Finality attacks: hostile test network of our own nodes, seven scenarios, bench-log entry
tools/finality-attacks: run.mjs drives a private igneum-devnet-800 network (ports 27800+,
/tmp/igneum-fin-attacks, skip_proof_of_work) with the test-only hostile flags of igneum-miner
(vmine, --equivocate, --sybil, --drop-votes, --pulse, fin-rpc-attack; worktree fin-attacks on
master c6d47547..2a00ff55). Seven scenarios in priority order 3,2,1,6,4,8,5 with a spec 03
criterion and a measured result each; README carries the catalogue, what needs a finality-aware
p2p probe, and a proposed diff for every FAIL.

Results (six voters): S3 dishonest aggregators PASS (35/35/35 locks, 0 conflicts, 1,018 ms);
S2 Sybil dust: weights PASS, aggregator sortition FAIL (per key, ledger F17); S1 equivocation
PASS (2/2/2 stripped, 0 conflicts); S6A 3/3 partition FAIL (floor is time-bounded, one side
crossed 56.7% at 84 s of a 90 s split, T* = 2F/13R, 9.2 days for a 50/50 split at mainnet
scale); S6B 4/2 PASS; S4 vote-dropping producer PASS (0 ms added); S8 malformed votes over
RPC PASS (9 cases, no crash); S5 pulse: no retarget amplification (ratio 0.999) PASS,
lock-alone FAIL (a 20 s burst locked checkpoints 1 to 10 alone on a young window, ledger F1,
spec 3.8 not implemented). S7 eclipse not run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 23:50:02 +00:00

54 lines
2.6 KiB
JavaScript

// wRPC JSON client for igneumd. Node 22, no dependencies. Method names are the lowerCamelCase of the node's
// RpcApiOps (getBlockDagInfo, getFinalityCheckpoints, getFinalityWeights, submitFinalityVote, ...).
export class Rpc {
constructor(url, { timeoutMs = 10_000 } = {}) {
this.url = url; this.id = 0; this.pending = new Map(); this.ws = null; this.open = false;
this.timeoutMs = timeoutMs; this.onNotification = () => { };
}
connect() {
return new Promise((resolve) => {
const ws = new WebSocket(this.url); this.ws = ws;
ws.onopen = () => { this.open = true; resolve(true); };
ws.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch { return; }
if (m.id !== undefined && m.id !== null && this.pending.has(m.id)) {
const p = this.pending.get(m.id); this.pending.delete(m.id);
m.error ? p.reject(new Error(typeof m.error === 'string' ? m.error : (m.error.message || JSON.stringify(m.error)))) : p.resolve(m.params);
} else if (m.method) this.onNotification(m.method, m.params);
};
ws.onerror = () => { if (!this.open) resolve(false); };
ws.onclose = () => {
const wasOpen = this.open; this.open = false;
for (const p of this.pending.values()) p.reject(new Error('rpc closed'));
this.pending.clear();
if (!wasOpen) resolve(false);
};
setTimeout(() => { if (!this.open) { try { ws.close(); } catch { } resolve(false); } }, 3000);
});
}
close() { try { this.ws && this.ws.close(); } catch { } }
call(method, params = {}, timeoutMs = this.timeoutMs) {
return new Promise((resolve, reject) => {
if (!this.open) return reject(new Error('rpc not connected'));
const id = ++this.id; this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`${method} timed out`)); } }, timeoutMs);
});
}
}
export async function connectRpc(url, { attempts = 60, waitMs = 500 } = {}) {
for (let i = 0; i < attempts; i++) {
const rpc = new Rpc(url);
if (await rpc.connect()) {
try { await rpc.call('getInfo'); return rpc; } catch { rpc.close(); }
}
await new Promise(r => setTimeout(r, waitMs));
}
throw new Error(`could not reach ${url}`);
}
// Convenience readers (camelCase fields from the node's RPC model).
export async function checkpoints(rpc, last = 40) { return rpc.call('getFinalityCheckpoints', { last }); }
export async function weights(rpc) { return rpc.call('getFinalityWeights', {}); }