Adversarial robustness and conformance tests of the execution layer against a throwaway 3-node simnet on ports 27600+. Six scenarios, each a runnable command with a design-derived pass criterion and a measured result: malformed/boundary txs, nonce games across parallel blocks, RPC fuzz, pgas exhaustion, reorgs under execution, and developer-registry abuse. 98 checks, 0 failures, 0 node panics. Two findings filed in the bench-log entry: the mempool admits txs with gas_limit above B_e (low), and an over-pgas-budget tx is executed natively in full before being skipped for no fee (medium, griefing). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
120 lines
6.7 KiB
JavaScript
120 lines
6.7 KiB
JavaScript
// Scenario 5: RPC fuzz. Every eth_* and igneum_* method with junk params, huge arrays and deep JSON nesting, plus
|
|
// a concurrent flood of eth_call at 50x the honest rate from one client. Design 8.2 surface. Criterion: the node
|
|
// returns errors, never crashes, and an honest client's latency stays under 200 ms during the flood.
|
|
import * as k from './lib/common.mjs';
|
|
|
|
const RPC_URL = k.URLS[0];
|
|
const REGISTRY = '0x0000000000000000000000000000000000000210';
|
|
const results = { scenario: '5-rpc-fuzz', malformed: [], flood: {} };
|
|
const checks = new k.Checks();
|
|
|
|
// Raw JSON-RPC POST that tolerates any body and never throws on a non-2xx; returns {status, json|text, ms, threw}.
|
|
async function raw(body, { timeoutMs = 5000 } = {}) {
|
|
const ctrl = new AbortController();
|
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
const start = performance.now();
|
|
try {
|
|
const res = await fetch(RPC_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: ctrl.signal });
|
|
const text = await res.text();
|
|
let json = null; try { json = JSON.parse(text); } catch {}
|
|
return { status: res.status, json, text: text.slice(0, 200), ms: performance.now() - start, threw: false };
|
|
} catch (e) {
|
|
return { status: 0, json: null, text: String(e).slice(0, 120), ms: performance.now() - start, threw: true };
|
|
} finally { clearTimeout(t); }
|
|
}
|
|
|
|
const METHODS = [
|
|
'eth_chainId', 'eth_blockNumber', 'eth_gasPrice', 'eth_maxPriorityFeePerGas', 'eth_feeHistory', 'eth_getBalance',
|
|
'eth_getTransactionCount', 'eth_getCode', 'eth_getStorageAt', 'eth_getBlockByNumber', 'eth_getBlockByHash',
|
|
'eth_getBlockTransactionCountByNumber', 'eth_getBlockReceipts', 'eth_getTransactionByHash', 'eth_getTransactionReceipt',
|
|
'eth_getTransactionByBlockNumberAndIndex', 'eth_getLogs', 'eth_sendRawTransaction', 'eth_call', 'eth_estimateGas',
|
|
'eth_syncing', 'net_version', 'web3_clientVersion', 'eth_accounts', 'eth_mining', 'net_listening', 'net_peerCount',
|
|
'igneum_getTransactionStatus', 'igneum_getSegment', 'igneum_getBudgets', 'igneum_exportSegments',
|
|
];
|
|
|
|
const JUNK_PARAMS = [
|
|
[],
|
|
[null],
|
|
[123, 'two', { x: 1 }, [1, 2, 3]],
|
|
['0xnothex'],
|
|
['0x' + 'f'.repeat(2000)],
|
|
[{ to: '0xdeadbeef', data: 'nothex', gas: -1 }],
|
|
[true, false, 3.14159],
|
|
['latest', 'latest', 'latest', 'latest'],
|
|
[Array.from({ length: 50_000 }, (_, i) => i)], // huge array
|
|
];
|
|
|
|
function deepNest(depth) {
|
|
// A params value that is depth-deep nested arrays, as a raw JSON string.
|
|
let s = '';
|
|
for (let i = 0; i < depth; i++) s += '[';
|
|
s += '1';
|
|
for (let i = 0; i < depth; i++) s += ']';
|
|
return `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[${s}]}`;
|
|
}
|
|
|
|
async function main() {
|
|
await k.waitTip(2);
|
|
|
|
// 1. Every method with several junk param shapes: each must come back as JSON (status 200) and not hang/crash.
|
|
let malformedOk = true;
|
|
for (const method of METHODS) {
|
|
for (const params of JUNK_PARAMS) {
|
|
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
|
|
const r = await raw(body);
|
|
// Acceptable: a JSON-RPC envelope (result or error). Not acceptable: a thrown/aborted request or a 5xx with no JSON.
|
|
const good = !r.threw && r.json !== null && (('result' in r.json) || ('error' in r.json));
|
|
if (!good) { malformedOk = false; results.malformed.push({ method, params: JSON.stringify(params).slice(0, 60), status: r.status, text: r.text, threw: r.threw }); }
|
|
}
|
|
}
|
|
checks.check(malformedOk, `every method with junk params returns a JSON-RPC envelope (${results.malformed.length} bad)`);
|
|
|
|
// 2. Deep JSON nesting and a few outright-broken bodies: a parse error, not a crash.
|
|
const broken = [deepNest(5000), '{not json', '', '[]', '{"jsonrpc":"2.0"}', JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'does_not_exist', params: [] })];
|
|
let brokenOk = true;
|
|
for (const b of broken) {
|
|
const r = await raw(b);
|
|
const good = !r.threw; // the server responds (even an HTTP 400 with a body is fine) rather than dropping the connection
|
|
if (!good) { brokenOk = false; results.malformed.push({ body: b.slice(0, 40), status: r.status, threw: r.threw }); }
|
|
}
|
|
checks.check(brokenOk, 'deep nesting and broken bodies are handled without dropping the connection');
|
|
|
|
// 3. Honest-latency baseline, then under a 50x eth_call flood from one client.
|
|
const sampleHonest = async () => { const r = await raw(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getBalance', params: [k.miner.address, 'latest'] })); return r.ms; };
|
|
const baseline = [];
|
|
for (let i = 0; i < 20; i++) { baseline.push(await sampleHonest()); await k.sleep(50); }
|
|
const p = (a, q) => a.slice().sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor(a.length * q))];
|
|
|
|
// Honest rate ~20 req/s (one every 50 ms). 50x = ~1000 eth_call/s. Fire a sustained burst with bounded concurrency.
|
|
const floodCall = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to: REGISTRY, data: '0x' }, 'latest'] });
|
|
let stop = false, floodCount = 0, floodErr = 0;
|
|
const worker = async () => { while (!stop) { const r = await raw(floodCall, { timeoutMs: 4000 }); floodCount++; if (r.threw || !r.json) floodErr++; } };
|
|
const CONCURRENCY = 50;
|
|
const workers = Array.from({ length: CONCURRENCY }, worker);
|
|
// Measure honest latency during the flood for ~4 s.
|
|
const during = [];
|
|
const floodStart = performance.now();
|
|
for (let i = 0; i < 40; i++) { during.push(await sampleHonest()); await k.sleep(50); }
|
|
stop = true;
|
|
await Promise.allSettled(workers);
|
|
const floodSecs = (performance.now() - floodStart) / 1000;
|
|
const rate = floodCount / floodSecs;
|
|
|
|
const honestP95 = p(during, 0.95), honestMax = Math.max(...during);
|
|
checks.check(rate >= 20 * 20, `flood sustained >= ~400 eth_call/s (actual ${rate.toFixed(0)}/s over ${floodSecs.toFixed(1)}s)`);
|
|
checks.check(honestP95 < 200, `honest p95 latency under 200 ms during flood (p95 ${honestP95.toFixed(1)} ms, max ${honestMax.toFixed(1)} ms)`);
|
|
|
|
// 4. Node still alive and advancing after all of it.
|
|
const t0 = Number(await k.rpc(k.node1, 'eth_blockNumber'));
|
|
await k.sleep(3000);
|
|
const t1 = Number(await k.rpc(k.node1, 'eth_blockNumber'));
|
|
checks.check(t1 > t0, `node still advancing after fuzz (${t0} -> ${t1})`);
|
|
|
|
results.flood = { baselineP50: p(baseline, 0.5), baselineMax: Math.max(...baseline), honestP50: p(during, 0.5), honestP95, honestMax, floodCount, floodErr, ratePerSec: Math.round(rate), floodSecs: +floodSecs.toFixed(1) };
|
|
const s = checks.summary('scenario 5');
|
|
results.summary = s;
|
|
const { writeFileSync } = await import('node:fs');
|
|
writeFileSync(new URL('./results/scenario5.json', import.meta.url), JSON.stringify(results, null, 2));
|
|
process.exit(s.ok ? 0 : 1);
|
|
}
|
|
main().catch((e) => { console.error(e); process.exit(2); });
|