igneum/tools/exec-attacks/scenario4_registry.mjs
igneum-josh 1d58fd8726 exec-attacks: execution-layer attack suite (tools + bench log)
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>
2026-10-03 22:57:19 +00:00

153 lines
11 KiB
JavaScript

// Scenario 4: developer-registry abuse (design 4.5). Registering a payee for someone else's code, factory
// inheritance edge cases (CREATE, CREATE2, same-tx override, unregistered factory, EOA override attempt), and
// self-dealing: a developer that also mines its own blocks to collect its own tips. Criterion per design 4.5
// (base fees are burned, no positive-expectation loop); we record the maximum share a self-dealer recovers.
import { encodeFunctionData, decodeFunctionResult, keccak256, getContractAddress, parseEther } from 'viem';
import { readFileSync } from 'node:fs';
import * as k from './lib/common.mjs';
const Worker = JSON.parse(readFileSync(new URL('./contracts/Worker.json', import.meta.url)));
const Factory = JSON.parse(readFileSync(new URL('./contracts/Factory.json', import.meta.url)));
const REG = '0x0000000000000000000000000000000000000210';
const REG_ABI = [
{ type: 'function', name: 'register', inputs: [{ type: 'address' }, { type: 'address' }], outputs: [] },
{ type: 'function', name: 'payeeOf', stateMutability: 'view', inputs: [{ type: 'address' }], outputs: [{ type: 'address' }] },
{ type: 'function', name: 'creatorOf', stateMutability: 'view', inputs: [{ type: 'address' }], outputs: [{ type: 'address' }] },
];
const results = { scenario: '4-registry-abuse', cases: [], selfDealing: null };
const checks = new k.Checks();
// The child address from a Factory call: the Created(address) event is the log emitted BY the factory (register's
// Registered event is emitted by the registry, so we cannot rely on log order).
function childFrom(receipt, factory) {
const log = (receipt?.logs || []).find((l) => l.address.toLowerCase() === factory.toLowerCase());
return log ? ('0x' + log.data.slice(26)) : null;
}
const call = (to, data) => k.rpc(k.node1, 'eth_call', [{ to, data }, 'latest']);
async function payeeOf(a) { return decodeFunctionResult({ abi: REG_ABI, functionName: 'payeeOf', data: await call(REG, encodeFunctionData({ abi: REG_ABI, functionName: 'payeeOf', args: [a] })) }); }
async function creatorOf(a) { return decodeFunctionResult({ abi: REG_ABI, functionName: 'creatorOf', data: await call(REG, encodeFunctionData({ abi: REG_ABI, functionName: 'creatorOf', args: [a] })) }); }
// Does an eth_call revert? Returns true if it throws (reverts).
async function reverts(from, to, data) {
try { await k.rpc(k.node1, 'eth_call', [{ from, to, data }, 'latest']); return false; } catch { return true; }
}
// Send a signed tx and wait for its receipt.
async function sendWait(account, fields, timeout = 30_000) {
const raw = await k.signTx(account, fields);
const s = await k.send(k.node1, raw);
const r = await k.waitReceipt(keccak256(raw), timeout);
return { raw, hash: keccak256(raw), sent: s, receipt: r };
}
async function deploy(account, artifact, gas = 600_000n) {
const n = await k.nonceOf(account);
const addr = getContractAddress({ from: account.address, nonce: BigInt(n) });
const r = await sendWait(account, { nonce: n, to: null, data: artifact.bytecode, gas });
return { addr: r.receipt?.contractAddress ?? addr, receipt: r.receipt };
}
async function main() {
await k.waitTip(2);
await k.fund([k.A, k.B, k.C, k.D, k.E ?? k.C]);
// ---- 1. Register a payee for someone else's code: must revert; payee unchanged. ----
const w1 = await deploy(k.A, Worker);
checks.check(w1.receipt && w1.receipt.contractAddress, `deployed Worker from A (${w1.addr})`);
const creatorW1 = await creatorOf(w1.addr);
checks.check(creatorW1.toLowerCase() === k.A.address.toLowerCase(), `creatorOf(worker) == A (${creatorW1})`);
// B (not the account, not the creator) tries to register a payee for A's contract.
const badData = encodeFunctionData({ abi: REG_ABI, functionName: 'register', args: [w1.addr, k.B.address] });
const rev1 = await reverts(k.B.address, REG, badData);
checks.check(rev1, 'register(worker, B) by B reverts (not the account or its creator)');
// B tries to register a payee for an unrelated EOA.
const rev2 = await reverts(k.B.address, REG, encodeFunctionData({ abi: REG_ABI, functionName: 'register', args: [k.C.address, k.B.address] }));
checks.check(rev2, 'register(C_eoa, B) by B reverts');
const payeeW1 = await payeeOf(w1.addr);
checks.check(payeeW1 === '0x0000000000000000000000000000000000000000', `worker payee still unset after the failed registrations (${payeeW1})`);
results.cases.push({ name: 'register-for-others', creatorW1, rev1, rev2, payeeW1 });
// ---- 2. Factory inheritance edge cases ----
const fac = await deploy(k.A, Factory, 900_000n);
checks.check(fac.receipt && fac.addr, `deployed Factory from A (${fac.addr})`);
// Factory registers itself with payee D.
await sendWait(k.A, { nonce: await k.nonceOf(k.A), to: fac.addr, data: encodeFunctionData({ abi: Factory.abi, functionName: 'registerSelf', args: [k.D.address] }), gas: 200_000n });
const facPayee = await payeeOf(fac.addr);
checks.check(facPayee.toLowerCase() === k.D.address.toLowerCase(), `factory self-registered payee = D (${facPayee})`);
// CREATE child inherits the factory payee.
const createData = encodeFunctionData({ abi: Factory.abi, functionName: 'createChild', args: [] });
const childPredict = getContractAddress({ from: fac.addr, nonce: 1n, opcode: 'CREATE' });
const cr = await sendWait(k.A, { nonce: await k.nonceOf(k.A), to: fac.addr, data: createData, gas: 500_000n });
const child1 = childFrom(cr.receipt, fac.addr) ?? childPredict;
const child1Payee = await payeeOf(child1), child1Creator = await creatorOf(child1);
checks.check(child1Creator.toLowerCase() === fac.addr.toLowerCase(), `CREATE child creator == factory (${child1Creator})`);
checks.check(child1Payee.toLowerCase() === k.D.address.toLowerCase(), `CREATE child inherits factory payee D (${child1Payee})`);
// CREATE2 child inherits too.
const salt = '0x' + '11'.repeat(32);
const c2 = await sendWait(k.A, { nonce: await k.nonceOf(k.A), to: fac.addr, data: encodeFunctionData({ abi: Factory.abi, functionName: 'createChild2', args: [salt] }), gas: 500_000n });
const child2 = childFrom(c2.receipt, fac.addr);
const child2Payee = child2 ? await payeeOf(child2) : null;
checks.check(child2Payee && child2Payee.toLowerCase() === k.D.address.toLowerCase(), `CREATE2 child inherits factory payee D (${child2Payee})`);
// Same-tx override by the creator (factory) sets a different payee.
const co = await sendWait(k.A, { nonce: await k.nonceOf(k.A), to: fac.addr, data: encodeFunctionData({ abi: Factory.abi, functionName: 'createAndOverride', args: [k.C.address] }), gas: 500_000n });
const child3 = childFrom(co.receipt, fac.addr);
const child3Payee = child3 ? await payeeOf(child3) : null;
checks.check(child3Payee && child3Payee.toLowerCase() === k.C.address.toLowerCase(), `same-tx creator override sets child payee to C (${child3Payee})`);
// EOA cannot override a factory child's registration (not the account, not the creator).
const eoaOverride = await reverts(k.A.address, REG, encodeFunctionData({ abi: REG_ABI, functionName: 'register', args: [child1, k.A.address] }));
checks.check(eoaOverride, 'EOA (A) cannot override a factory child registration');
// Unregistered factory: its child inherits no payee (share will burn).
const fac2 = await deploy(k.B, Factory, 900_000n);
const u = await sendWait(k.B, { nonce: await k.nonceOf(k.B), to: fac2.addr, data: createData, gas: 500_000n });
const uchild = childFrom(u.receipt, fac2.addr);
const uchildPayee = uchild ? await payeeOf(uchild) : null;
checks.check(uchildPayee === '0x0000000000000000000000000000000000000000', `unregistered factory's child has no payee (${uchildPayee})`);
results.cases.push({ name: 'factory-inheritance', facPayee, child1Payee, child1Creator, child2Payee, child3Payee, eoaOverrideReverts: eoaOverride, unregisteredChildPayee: uchildPayee });
// ---- 3. Self-dealing: sender == payee == block miner (node1 mines to the `miner` account). ----
const SD = k.miner; // node1's single miner pays this address
const wsd = await deploy(SD, Worker);
checks.check(wsd.receipt && wsd.addr, `self-dealer deployed Worker (${wsd.addr})`);
// SD registers itself as the payee of its own contract (SD is the creator).
await sendWait(SD, { nonce: await k.nonceOf(SD), to: REG, data: encodeFunctionData({ abi: REG_ABI, functionName: 'register', args: [wsd.addr, SD.address] }), gas: 120_000n });
const sdPayee = await payeeOf(wsd.addr);
checks.check(sdPayee.toLowerCase() === SD.address.toLowerCase(), `self-dealer registered its own payee (${sdPayee})`);
// SD calls its Worker with a healthy priority fee so a tip exists; the including block is mined by SD.
const workData = encodeFunctionData({ abi: Worker.abi, functionName: 'work', args: [2000n] });
const sd = await sendWait(SD, { nonce: await k.nonceOf(SD), to: wsd.addr, data: workData, gas: 1_000_000n, maxFeePerGas: 5_000_000_000n, maxPriorityFeePerGas: 3_000_000_000n });
const ig = sd.receipt?.igneum;
checks.check(!!ig, 'self-dealing call produced an igneum receipt with fee breakdown');
if (ig) {
const minerTip = BigInt(ig.minerTip);
const devToSD = (ig.developerShares || []).filter((s) => s.payee && s.payee.toLowerCase() === SD.address.toLowerCase()).reduce((a, s) => a + BigInt(s.wei), 0n);
const devTotal = (ig.developerShares || []).reduce((a, s) => a + BigInt(s.wei), 0n);
const burnedExec = BigInt(ig.burnedExecutionBaseFee);
const burnedProving = BigInt(ig.burnedProvingFee);
const totalPaid = minerTip + devTotal + burnedExec + burnedProving;
const recovered = minerTip + devToSD; // SD is both the block miner and the payee
const tip = minerTip + devTotal;
const shareOfTotal = Number(recovered) / Number(totalPaid);
const shareOfTip = Number(recovered) / Number(tip);
checks.check(burnedExec > 0n && burnedProving > 0n, `both base fees are burned (exec ${burnedExec}, proving ${burnedProving})`);
checks.check(recovered < totalPaid, `self-dealer recovers less than it pays (no positive-expectation loop): recovered ${recovered} < paid ${totalPaid}`);
checks.check(shareOfTip > 0.99, `self-dealer recovers ~all of the tip (${(shareOfTip * 100).toFixed(1)}%)`);
results.selfDealing = {
minerTip: minerTip.toString(), devToSD: devToSD.toString(), devTotal: devTotal.toString(),
burnedExec: burnedExec.toString(), burnedProving: burnedProving.toString(), totalPaid: totalPaid.toString(),
recovered: recovered.toString(), maxShareOfTotalRecovered: +shareOfTotal.toFixed(4), shareOfTipRecovered: +shareOfTip.toFixed(4),
};
}
const s = checks.summary('scenario 4');
results.summary = s;
const { writeFileSync } = await import('node:fs');
writeFileSync(new URL('./results/scenario4.json', import.meta.url), JSON.stringify(results, null, 2));
process.exit(s.ok ? 0 : 1);
}
main().catch((e) => { console.error(e); process.exit(2); });