igneum/tools/harness/lib/rpc.mjs
igneum-labs 735e15cb2c Harness: consensus attack catalogue runner and first results
tools/harness runs the standard consensus-attack catalogue against a private
test network of our own igneumd nodes (127.0.0.1 ports 27200+, /tmp/igneum-harness,
never the live devnet or the PC node), with a pass criterion per scenario from the
spec and a measured result each. Built on the node fork's own crates
(igneum-harness-sim on kaspa_utils::sim as simpa does; igneum-p2p-probe for the
wire). Scenarios: 1 withholding, 2 timestamp edges and drift, 3 partition and heal,
4 eclipse, 5 malformed and boundary inputs on every p2p and RPC surface, 6 resource
exhaustion, 7 fast-miner flood. Finality and difficulty-controller scenarios are
stubs with their criteria written.

bench-log: one dated entry, a row per scenario (criterion, measured, pass or fail).
First run: 19 of 20 measured rows pass. Findings recorded in the entry: scenario 5
reproduces ledger M15 on HEAD (bogus past-day or DAA headers build a 256 MiB cache
before rejection; the r3-fixes branch removes it); scenario 1 at 45% hash with
burst withholding shows a selfish-mining blue-share gain (50.7% of blues), the one
failing row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 22:17:37 +00:00

71 lines
3.6 KiB
JavaScript

// wRPC JSON client for igneumd (same shape as tools/observer/observer.mjs). Node 22, no dependencies.
// Method names are the lowerCamelCase of the node's RpcApiOps (getBlockTemplate, submitBlock, ...).
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);
};
// A refused connection may fire error without close in Node's WebSocket: resolve false either way, with a timer.
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);
});
}
/// Sends a raw frame (for malformed-input cases) and resolves with the first response or an error.
raw(text, timeoutMs = 3000) {
return new Promise((resolve) => {
if (!this.open) return resolve({ error: 'rpc not connected' });
const id = ++this.id;
this.pending.set(id, { resolve: (p) => resolve({ ok: p }), reject: (e) => resolve({ error: String(e.message || e) }) });
try { this.ws.send(text.replace('__ID__', String(id))); } catch (e) { this.pending.delete(id); return resolve({ error: String(e) }); }
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); resolve({ timeout: true }); } }, timeoutMs);
});
}
}
/// Connects with retries (the node takes a few seconds to open its listeners).
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}`);
}
/// Unwraps the node's SubmitBlockResponse into a short string: "accepted" or "rejected:<reason>".
export function submitReport(res) {
const r = res && res.report;
if (!r) return `odd:${JSON.stringify(res)}`;
if (r === 'success' || r.type === 'success') return 'accepted';
if (r.type === 'reject') return `rejected:${JSON.stringify(r.reason ?? r.reject ?? r)}`;
return `odd:${JSON.stringify(r)}`;
}