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>
144 lines
7.2 KiB
JavaScript
144 lines
7.2 KiB
JavaScript
// Shared helpers for the execution-layer attack scenarios: viem clients against the three igneumd eth_ RPCs on
|
|
// 27690/27691/27692, the test accounts, raw-transaction crafting (valid and deliberately malformed), funding and
|
|
// small polling utilities. Keys here are for the throwaway simnet only and are never used anywhere else.
|
|
import { createPublicClient, http, parseEther, keccak256, toRlp, toHex, concatHex, serializeTransaction } from 'viem';
|
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
|
|
export const CHAIN_ID = 4463;
|
|
export const URLS = (process.env.IGNEUM_RPCS ?? 'http://127.0.0.1:27690,http://127.0.0.1:27691,http://127.0.0.1:27692').split(',');
|
|
export const clients = URLS.map((u) => createPublicClient({ transport: http(u, { timeout: 20_000, retryCount: 0 }) }));
|
|
export const node1 = clients[0];
|
|
|
|
// Miner 1's EVM address is the low 20 bytes of its vote key hash; the net.sh launcher sets that from this key.
|
|
export const MINER_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d';
|
|
export const miner = privateKeyToAccount(MINER_KEY);
|
|
export const A = privateKeyToAccount('0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba');
|
|
export const B = privateKeyToAccount('0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a');
|
|
export const C = privateKeyToAccount('0x8166f546bab6da521a8369cab06c5d2b9e46670292d85c875ee9ec20e84ba4ea');
|
|
export const D = privateKeyToAccount('0xea6c44ac03bff858b476bba40716402b03e41b8e97e276d1baec7c37d42484a0');
|
|
|
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
export const rpc = (client, method, params = []) => client.request({ method, params });
|
|
export function log(...a) { console.log(new Date().toISOString().slice(11, 19), ...a); }
|
|
|
|
export class Checks {
|
|
constructor() { this.pass = 0; this.fail = 0; this.items = []; }
|
|
check(cond, msg) {
|
|
if (cond) { this.pass++; this.items.push({ ok: msg }); }
|
|
else { this.fail++; this.items.push({ fail: msg }); console.error(' FAIL:', msg); }
|
|
return cond;
|
|
}
|
|
summary(name) {
|
|
const line = `${name}: ${this.pass} passed, ${this.fail} failed`;
|
|
console.log(line);
|
|
return { name, pass: this.pass, fail: this.fail, ok: this.fail === 0, items: this.items };
|
|
}
|
|
}
|
|
|
|
// A signed, valid EIP-1559 transaction as raw EIP-2718 hex.
|
|
export async function signTx(account, { nonce, to, value = 0n, data = '0x', gas = 21000n, maxFeePerGas = 2_000_000_000n, maxPriorityFeePerGas = 1_000_000_000n, chainId = CHAIN_ID }) {
|
|
return account.signTransaction({ type: 'eip1559', chainId, nonce, to: to ?? undefined, value, data, gas, maxFeePerGas, maxPriorityFeePerGas });
|
|
}
|
|
|
|
// A legacy (type 0) transaction.
|
|
export async function signLegacy(account, { nonce, to, value = 0n, data = '0x', gas = 21000n, gasPrice = 2_000_000_000n, chainId = CHAIN_ID }) {
|
|
return account.signTransaction({ type: 'legacy', chainId, nonce, to: to ?? undefined, value, data, gas, gasPrice });
|
|
}
|
|
|
|
// Re-sign a transaction but then corrupt the signature's s value, so recovery yields a different (or no) sender.
|
|
export async function signThenBreakSig(account, fields) {
|
|
const raw = await signTx(account, fields);
|
|
// Flip the last byte of the raw bytes (inside the signature region) to invalidate recovery deterministically.
|
|
const bytes = raw.slice(2);
|
|
const flipped = bytes.slice(0, -2) + (parseInt(bytes.slice(-2), 16) ^ 0xff).toString(16).padStart(2, '0');
|
|
return '0x' + flipped;
|
|
}
|
|
|
|
export async function send(client, raw) {
|
|
try { return { hash: await rpc(client, 'eth_sendRawTransaction', [raw]), error: null }; }
|
|
catch (e) { return { hash: null, error: e.details || e.shortMessage || e.message }; }
|
|
}
|
|
|
|
export async function waitTip(minBlock = 1, timeoutMs = 60_000) {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
try { const n = BigInt(await rpc(node1, 'eth_blockNumber')); if (n >= BigInt(minBlock)) return Number(n); } catch {}
|
|
await sleep(400);
|
|
}
|
|
throw new Error('node not producing blocks');
|
|
}
|
|
|
|
export async function receiptOf(hash, client = node1) {
|
|
if (!hash) return null;
|
|
try { return await rpc(client, 'eth_getTransactionReceipt', [hash]); } catch { return null; }
|
|
}
|
|
|
|
export async function waitReceipt(hash, timeoutMs = 60_000, client = node1) {
|
|
if (!hash) return null;
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const r = await receiptOf(hash, client);
|
|
if (r) return r;
|
|
await sleep(400);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function nonceOf(account, client = node1) {
|
|
return Number(await rpc(client, 'eth_getTransactionCount', [account.address, 'latest']));
|
|
}
|
|
|
|
export async function balanceOf(address, client = node1) {
|
|
return BigInt(await rpc(client, 'eth_getBalance', [address, 'latest']));
|
|
}
|
|
|
|
// Fund accounts from the miner's rewards; waits until the miner has enough, then for the receipts.
|
|
export async function fund(targets, amountEther = '5') {
|
|
const need = parseEther(amountEther) * BigInt(targets.length) + parseEther('1');
|
|
const start = Date.now();
|
|
while ((await balanceOf(miner.address)) < need && Date.now() - start < 120_000) await sleep(1000);
|
|
let nonce = await nonceOf(miner);
|
|
const hashes = [];
|
|
for (const t of targets) {
|
|
const raw = await signTx(miner, { nonce: nonce++, to: t.address, value: parseEther(amountEther) });
|
|
const { hash, error } = await send(node1, raw);
|
|
if (error) throw new Error('funding failed: ' + error);
|
|
hashes.push(hash);
|
|
}
|
|
for (const h of hashes) if (!(await waitReceipt(h))) throw new Error('funding receipt missing');
|
|
return hashes;
|
|
}
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { writeFileSync, mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
export const INJECT_BIN = process.env.IGNEUM_INJECT ?? join(HERE, '..', '..', '..', 'vendor', 'igneum-node-exec-attacks', 'target', 'release', 'igneum-inject');
|
|
export const GRPC1 = process.env.IGNEUM_GRPC1 ?? 'grpc://127.0.0.1:27610';
|
|
|
|
// Submits one hostile block (array of raw hex) or several parallel blocks (array of arrays) through igneum-inject.
|
|
// Returns the parsed per-block JSON reports. Each block is built off one template so parallel blocks share a parent.
|
|
export function inject(job, { grpc = GRPC1, voteKeyHash = null } = {}) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'igneum-inject-'));
|
|
const file = join(dir, 'job.json');
|
|
writeFileSync(file, JSON.stringify(job));
|
|
const args = [grpc, 'block', file, '--prefix', 'simnet'];
|
|
if (voteKeyHash) args.push('--vote-key-hash', voteKeyHash);
|
|
const out = execFileSync(INJECT_BIN, args, { encoding: 'utf8' });
|
|
return out.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
}
|
|
|
|
export function injectCmd(cmdArgs, grpc = GRPC1) {
|
|
return execFileSync(INJECT_BIN, [grpc, ...cmdArgs], { encoding: 'utf8' }).trim();
|
|
}
|
|
|
|
// Resident set size (KiB) of every igneumd process in the attack network, for the memory-bounded check.
|
|
export function nodeRssKib() {
|
|
try {
|
|
const out = execFileSync('bash', ['-c', "ps -axo rss,command | grep 'igneum-node-exec-attacks/target/release/igneumd --simnet' | grep -v grep | awk '{print $1}'"], { encoding: 'utf8' });
|
|
return out.trim().split('\n').filter(Boolean).map(Number);
|
|
} catch { return []; }
|
|
}
|