Report docs/review/redteam-2026-10-04.md: finality attacks s1-s8 plus 34% withholding, 50/50 long partition, F23 and F24 custom runs, ordering harness, execution suite and EVM smoke, proving hostile tests and a proof flood, difficulty v2 timestamp forging in the simulator. New fails: the fast-time harnesses corrupt the u64::MAX sentinels of the override (F25), a block or transaction flood grows the node by hundreds of MB in a minute (M30), the coinbase does not fit the 204-byte limit on mainnet, testnet and simnet parameters (M31). F23 and F24 reproduced on this build; F21's bound measured at one window of the side's own DAA. Scenario scripts under tools/finality-attacks/redteam/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
106 lines
7.3 KiB
JavaScript
106 lines
7.3 KiB
JavaScript
// Red-team: flood of invalid proof records against the proof pool of the finality-fixes build (proving v0 active).
|
|
// One redteam node (eth RPC), 3 vmine voters to reach activation and assign shards, then a flood of well-formed-length
|
|
// garbage records through igneum_submitProofRecord. Measures reject throughput and node CPU per rejected record.
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdirSync, rmSync, openSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { connectRpc } from '../lib/rpc.mjs';
|
|
|
|
const ROOT = '/Users/joshm/Projects/igneum/';
|
|
const IGNEUMD = `${ROOT}vendor/igneum-node-redteam/target/release/igneumd`;
|
|
const MINER = `${ROOT}vendor/igneum-node-fin-attacks/target/release/igneum-miner`;
|
|
const OVERRIDE = '/tmp/igneum-redteam-override-prove-v3.json';
|
|
const TMP = '/tmp/igneum-redteam-flood';
|
|
const BASE = 29680, SUFFIX = 968;
|
|
const RECLEN = 2 + 32 + 8 + 4 + 48 + 20 + 32 + 32 + 96; // 274
|
|
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
const started = [];
|
|
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
|
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
|
|
|
class Node {
|
|
constructor(i, connect = []) { this.i = i; this.grpc = BASE + i * 10; this.p2p = BASE + i * 10 + 1; this.json = BASE + i * 10 + 2; this.evm = BASE + i * 10 + 3; this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; }
|
|
async start() {
|
|
mkdirSync(this.dir, { recursive: true });
|
|
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--unsaferpc',
|
|
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpc}`, `--rpclisten-json=127.0.0.1:${this.json}`, `--evm-rpclisten=127.0.0.1:${this.evm}`,
|
|
`--listen=127.0.0.1:${this.p2p}`, `--override-params-file=${OVERRIDE}`, '--loglevel=info', '--yes'];
|
|
if (this.connect.length) a.push(...this.connect.map(c => `--connect=${c}`)); else a.push('--outpeers=0');
|
|
const out = openSync(this.logFile, 'a');
|
|
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] }); started.push(this.proc);
|
|
await sleep(900); this.rpc = await connectRpc(`ws://127.0.0.1:${this.json}`);
|
|
log(`n${this.i} up pid ${this.proc.pid} evm ${this.evm}`); return this;
|
|
}
|
|
async eth(method, params = []) {
|
|
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
|
|
const r = await fetch(`http://127.0.0.1:${this.evm}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body });
|
|
const j = await r.json(); if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`); return j.result;
|
|
}
|
|
}
|
|
function miner(node, label) {
|
|
const a = ['vmine', `grpc://127.0.0.1:${node.grpc}`, '600', '--label', label, '--share', String(1 / 3), '--bps', '1'];
|
|
const out = openSync(`${TMP}/miner-${label}.log`, 'a'); const p = spawn(MINER, a, { stdio: ['ignore', out, out] }); started.push(p); return p;
|
|
}
|
|
function cpuOf(pid) { try { return parseFloat(spawnSync('ps', ['-o', '%cpu=,time=', '-p', String(pid)], { encoding: 'utf8' }).stdout.trim().split(/\s+/)[0]) || 0; } catch { return 0; } }
|
|
function cpuSecs(pid) { try { const t = spawnSync('ps', ['-o', 'time=', '-p', String(pid)], { encoding: 'utf8' }).stdout.trim(); const m = t.match(/(?:(\d+)-)?(\d+):(\d+):(\d+)|(\d+):(\d+)\.(\d+)/); if (!m) return 0; if (m[2] != null) return (+(m[1]||0))*86400 + (+m[2])*3600 + (+m[3])*60 + (+m[4]); return (+m[5])*60 + (+m[6]) + (+('0.'+m[7])); } catch { return 0; } }
|
|
|
|
// Build a well-formed-length record with controllable version and a matching/mismatching proof_hash.
|
|
function craftRecord({ version = 1, proofMatches = false } = {}) {
|
|
const proof = randomBytes(256);
|
|
const rec = Buffer.alloc(RECLEN);
|
|
let o = 0;
|
|
rec.writeUInt16LE(version & 0xffff, o); o += 2; // version
|
|
randomBytes(32).copy(rec, o); o += 32; // block
|
|
rec.writeBigUInt64LE(BigInt(1 + Math.floor(Math.random() * 1000)), o); o += 8; // number
|
|
rec.writeUInt32LE(0, o); o += 4; // shard
|
|
randomBytes(48).copy(rec, o); o += 48; // pubkey
|
|
randomBytes(20).copy(rec, o); o += 20; // payout
|
|
randomBytes(32).copy(rec, o); o += 32; // statement
|
|
const ph = proofMatches ? createHash('sha256').update(proof).digest() : randomBytes(32);
|
|
ph.copy(rec, o); o += 32; // proof_hash
|
|
randomBytes(96).copy(rec, o); o += 96; // signature
|
|
return { record: '0x' + rec.toString('hex'), proof: '0x' + proof.toString('hex') };
|
|
}
|
|
|
|
const out = { cases: [] };
|
|
try {
|
|
const n0 = await new Node(0).start();
|
|
['v0', 'v1', 'v2'].forEach(l => miner(n0, l));
|
|
const status0 = await n0.eth('igneum_getProvingStatus');
|
|
log(`proving status: activationDaa=${parseInt(status0.activationDaa,16)} verifier=${status0.verifier}`);
|
|
// wait for activation + a few assigned shards
|
|
let daa = 0, waited = 0;
|
|
while (daa < 55 && waited < 180) { await sleep(2000); waited += 2; const s = await n0.eth('igneum_getProvingStatus').catch(() => null); if (s) daa = parseInt(s.tipDaa, 16); if (waited % 10 === 0) log(`daa ${daa}`); }
|
|
log(`reached daa ${daa}`);
|
|
|
|
async function floodCase(name, opts, n) {
|
|
const c0 = cpuSecs(n0.proc.pid); const t0 = Date.now();
|
|
let accepted = 0, rejected = 0; const reasons = {};
|
|
for (let k = 0; k < n; k++) {
|
|
const { record, proof } = craftRecord(opts);
|
|
try { const r = await n0.eth('igneum_submitProofRecord', [{ record, proof }]); if (r.accepted) accepted++; else { rejected++; reasons[r.reason] = (reasons[r.reason] || 0) + 1; } }
|
|
catch (e) { rejected++; const m = String(e.message).slice(0, 60); reasons[m] = (reasons[m] || 0) + 1; }
|
|
}
|
|
const wall = (Date.now() - t0) / 1000; const c1 = cpuSecs(n0.proc.pid);
|
|
const row = { case: name, submitted: n, accepted, rejected, wallSecs: +wall.toFixed(2), rate: +(n / wall).toFixed(1), nodeCpuSecs: +(c1 - c0).toFixed(2), cpuMsPerRecord: +(((c1 - c0) * 1000) / n).toFixed(3), reasons };
|
|
out.cases.push(row); log(`CASE ${name}: ${JSON.stringify(row)}`);
|
|
}
|
|
await floodCase('proof_hash-mismatch (cheapest)', { proofMatches: false, version: 1 }, 2000);
|
|
await floodCase('bad-version (passes proof_hash)', { proofMatches: true, version: 0xbbbb }, 2000);
|
|
await floodCase('v1-garbage (reaches record lookup)', { proofMatches: true, version: 1 }, 2000);
|
|
|
|
const up = await n0.eth('eth_blockNumber').catch(() => null);
|
|
const status1 = await n0.eth('igneum_getProvingStatus').catch(() => null);
|
|
out.nodeUpAfter = up != null;
|
|
out.poolAfter = status1 && status1.pool;
|
|
log(`node up after flood: ${out.nodeUpAfter}; pool ${JSON.stringify(out.poolAfter)}`);
|
|
out.ok = out.nodeUpAfter && out.cases.every(c => c.accepted === 0);
|
|
} catch (e) { out.error = e.message; log(`FAILED: ${e.message}`); }
|
|
finally {
|
|
writeFileSync(`${TMP}/flood.json`, JSON.stringify(out, null, 2));
|
|
console.log(JSON.stringify(out, null, 2));
|
|
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch {} }
|
|
await sleep(1500); for (const p of started) { try { p.kill('SIGKILL'); } catch {} }
|
|
process.exit(out.ok ? 0 : 1);
|
|
}
|