// 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 []; } }