// 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); });