From 72d7bff0ee3be69cd9515a2dd516b11e9bc22af3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:57:19 +0000 Subject: [PATCH] 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 --- docs/bench-log.md | 42 +++++ tools/exec-attacks/.gitignore | 2 + tools/exec-attacks/README.md | 61 +++++++ tools/exec-attacks/compile.mjs | 25 +++ tools/exec-attacks/contracts/Factory.json | 82 +++++++++ tools/exec-attacks/contracts/PgasBomb.json | 62 +++++++ tools/exec-attacks/contracts/PgasBomb.sol | 46 +++++ .../exec-attacks/contracts/RegistryAbuse.sol | 41 +++++ tools/exec-attacks/contracts/Worker.json | 37 ++++ tools/exec-attacks/lib/common.mjs | 144 ++++++++++++++++ tools/exec-attacks/net.sh | 98 +++++++++++ tools/exec-attacks/run_all.sh | 36 ++++ tools/exec-attacks/run_scenario6.sh | 20 +++ tools/exec-attacks/scenario1_malformed.mjs | 161 ++++++++++++++++++ tools/exec-attacks/scenario2_nonce.mjs | 90 ++++++++++ tools/exec-attacks/scenario3_pgas.mjs | 88 ++++++++++ tools/exec-attacks/scenario4_registry.mjs | 153 +++++++++++++++++ tools/exec-attacks/scenario5_rpcfuzz.mjs | 120 +++++++++++++ tools/exec-attacks/scenario6_reorg.mjs | 122 +++++++++++++ 19 files changed, 1430 insertions(+) create mode 100644 tools/exec-attacks/.gitignore create mode 100644 tools/exec-attacks/README.md create mode 100644 tools/exec-attacks/compile.mjs create mode 100644 tools/exec-attacks/contracts/Factory.json create mode 100644 tools/exec-attacks/contracts/PgasBomb.json create mode 100644 tools/exec-attacks/contracts/PgasBomb.sol create mode 100644 tools/exec-attacks/contracts/RegistryAbuse.sol create mode 100644 tools/exec-attacks/contracts/Worker.json create mode 100644 tools/exec-attacks/lib/common.mjs create mode 100755 tools/exec-attacks/net.sh create mode 100755 tools/exec-attacks/run_all.sh create mode 100755 tools/exec-attacks/run_scenario6.sh create mode 100644 tools/exec-attacks/scenario1_malformed.mjs create mode 100644 tools/exec-attacks/scenario2_nonce.mjs create mode 100644 tools/exec-attacks/scenario3_pgas.mjs create mode 100644 tools/exec-attacks/scenario4_registry.mjs create mode 100644 tools/exec-attacks/scenario5_rpcfuzz.mjs create mode 100644 tools/exec-attacks/scenario6_reorg.mjs diff --git a/docs/bench-log.md b/docs/bench-log.md index 283da8edb..f02ed2a55 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -336,3 +336,45 @@ Rules: G1 exactly 16 load slots drawn first from slots 1..63; G2 a load reads on Closed-form check: with the same seeds and nonces on the closed-form dataset instead of the memory-hard one, R-c agrees on 99,961 of the 100,000 proposed-generator programs (2,054 rejected memory-hard, 2,055 closed-form; the 39 that differ sit at a threshold edge, one nearly constant bit or a bias near 6 sigma) and the per-program metrics agree to three decimals, so the acceptance test can be a pure function of the program. Hash-rate spread: today 2.7x between the 1st and 99th percentile program by distinct loads (56 to 152 per hash; 321 to 118 Mhash/s projected on the RTX 5090 at 18.0 G distinct loads/s), 8.3x min to max; under G1 + G2 every program does 128 distinct loads, projected 141 Mhash/s on the 5090 and 28 on the M5 Max, with the 1.10x program-shape residual the only spread left, approximate. One 5090 run of `igneum-second-seed` (predicted 173 Mhash/s if distinct-bound, 228 if static-bound) settles the reading on NVIDIA. Not done: no GPU run of the new generator; the spec text is proposed in the analysis doc, section 9, not written into `docs/spec/01-lottery-hash.md`; test vectors are re-cut when the generator rule is adopted. + +## 3 October 2026, proving v0: first SP1 proof of an Igneum block, Apple M5 Max CPU, loaded machine (execution-engineer, proving) + +Machine: Apple M5 Max (18 cores, 64 GB), macOS Darwin 25.6.0, load average 14 to 45 during the runs (the live devnet, the observer and other agents' builds were running), everything under `nice -n 19`. Toolchain: SP1 v6.8.1 (`sp1up`, cargo-prove c84ada1 of 24 Sep 2026, succinct rustc 1.96.0-dev, circuit version v6.1.0), sp1-sdk 6.8.1 CPU prover, revm 43.0.3, alloy-primitives 1.7.3 with SP1's sha3 patch and k256 patch. Code: `proving/igneum-prove` (guest ELF 2.69 MB, host 54 MB), fixtures cut from `tools/evm-smoke/seq.json` (the 3-node simnet export, execution-layer commit fb33069) by `igneum-prove-export`, which replayed all 79 segments from genesis through the ported executor and matched every one of the node's state roots (final root `0x5b18b3a5...`). +Statement: re-execute one chain block (rewards by rule, nonce-rule skip, two-dimensional gas with the prototype pgas table, fee flows with the developer split, state root over the whole in-memory state) and commit the pre-root, post-root, receipts root, gas, pgas and the executed and skipped counts; the host checks the guest's public values against its own native run before and after each proof. + +| Fixture | Txs (executed / skipped) | EVM gas | pgas | Pre-state accounts | SP1 cycles | Prover gas | Cycles per EVM gas | Execute s | +|---|---|---|---|---|---|---|---|---| +| block-78-increment (Counter `increment(5)` plus a duplicate copy skipped by the nonce rule) | 2 (1 / 1) | 45,354 | 1,488 | 10 | 626,246 | 843,343 | 14 | 0.03 | +| block-56-transfers (three funding transfers) | 3 (3 / 0) | 63,000 | 600 | 5 | 549,469 | 733,297 | 9 | 0.03 | + +| block-78-increment, CPU prover | Prove s | Proof bytes | Verify s | Verified | +|---|---|---|---|---| +| Setup (pk, vk; vk hash `0x00c3a917...`) | 6.9 | | | | +| Core (STARK shards) | 22.0 | 7,317,217 | 0.164 | yes | +| Compressed (recursion, one shard) | 55.7 | 1,272,769 | 0.033 | yes | + +Reading: at 626 k cycles the block is far below one SP1 shard, so these times are fixed overhead (proof system setup and the recursion stack), not throughput; cycles per EVM gas (9 to 14) is the first data point for the pgas table calibration (R1) and is dominated by the state-root computation over every account plus one secp256k1 recovery per transaction (through the patched k256). Nothing here is a 12 GB-card shard time (ledger P1); that is the RTX 5090 run of `proving/windows-wsl2/` and then the 3060-class gate. The devnet was not touched. +Not done: the Groth16 or Plonk wrapper (ledger P3), MPT witnesses, more than one shard per block, chain recursion, the prover key in the statement (P12). + +## 2026-10-03 execution layer attack suite: malformed txs, nonce games, RPC fuzz, pgas exhaustion, reorgs, registry abuse (execution test engineer) + +Machine: Apple M5 Max (18 cores), shared with other agents' builds (load 9 to 15). Worktree `vendor/igneum-node-exec-attacks` on branch `exec-attacks` (from `execution-layer` fb330692); `igneumd`, `igneum-miner` and a new hostile-miner bin `igneum-inject` built release with `CARGO_TARGET_DIR=target nice -n 19 cargo build -j 4 -p kaspad -p igneum-miner --features igneum-pow` (stable-aarch64 toolchain; the default cargo on PATH is too old for edition 2024). Tools and the per-scenario commands: `tools/exec-attacks/` (README, `net.sh`, `scenario{1,2,3,4,5,6}*.mjs`, `igneum-inject`); raw results under `tools/exec-attacks/results/*.json`. Network: 3 `igneumd --simnet --enable-unsynced-mining --unsaferpc --disable-upnp` nodes, PoW skipped, chain id 4463, eth RPC 27690/27691/27692, gRPC 27610/27620/27630, p2p 27611/27621/27631, appdir `/tmp/igneum-exec-attacks`; one honest stub miner for scenarios 1 to 5 and 4, three miners split into partitions for scenario 6. `igneum-inject` fetches a block template, replaces the EVM body with arbitrary raw EIP-2718 bytes, recomputes `hash_merkle_root` and resubmits, so the hostile-miner path reaches body validation and the executor directly. The live devnet (26610, 26611, 26640, 26641, 28640) and other agents' ports (up to 27599) were not touched; every process was stopped at the end. + +Run in priority order 1, 2, 5, 3, 6, 4. One row per scenario: criterion (from the design), measured result, verdict. + +| # | Scenario | Criterion | Result | Verdict | +|---|---|---|---|---| +| 1 | Malformed and boundary txs (mempool and hostile block) | State-free faults invalidate the block; state-dependent faults skip the tx with no receipt; no panic; memory bounded | 7 state-free faults (bad RLP, type-3 blob, wrong chain id, intrinsic gas above limit, initcode above 49,152, duplicate hash in block, non-contiguous nonces, invalid signature s=0) each made the hostile block invalid and were rejected by the mempool where decodable; 5 state-dependent faults (nonce far ahead, nonce reuse, zero fee below base, insufficient funds, max fee at 2^120) each landed in an accepted block and were skipped with no receipt; gas limit exactly at B_e executed; node kept producing blocks; node RSS 345 MiB to 348 MiB (x1.01); 0 node panics in any log | PASS (30/30 checks) | +| 2 | Nonce games across parallel blocks | Exactly one execution per nonce; deterministic; state roots identical on all nodes | nonces n..n+3 spread across 3 parallel blocks with heavy duplication executed once each, account nonce advanced to n+4; a conflicting same-nonce pair in two parallel blocks executed exactly once; state roots identical on all 3 nodes at the tip in both rounds | PASS (9/9) | +| 5 | RPC fuzz | Errors not crashes; honest latency under 200 ms | 31 `eth_*`/`igneum_*` methods x 9 junk param shapes plus deep nesting (5,000 levels) and broken bodies all returned a JSON-RPC envelope or a handled HTTP error, none dropped the connection or crashed; under a one-client `eth_call` flood of 4,184 req/s (about 200x honest) honest p95 latency 29.1 ms, max 33.5 ms, 0 flood errors; node kept advancing | PASS (5/5) | +| 3 | Proving-gas (pgas) exhaustion | The per-block pgas budget B_p caps inclusion and the template respects it; measure execution time per block | B_p = 30,000,000. modexp loops: 1,000 iters executed 3.45 M pgas in 1.34 ms; 3,000 -> 10.33 M pgas, 4.56 ms; 6,000 -> 20.64 M pgas, 7.54 ms; 9,000 -> would-be 30.96 M pgas, skipped with `BlockProvingBudget` after 10.85 ms of native execution; no executed block carried more than B_p (max 20.64 M) | PASS (4/4) | +| 6 | Reorgs under execution | State root recomputed deterministically; displaced-tx receipts handled per design; no stuck mempool | Partition P1={node1}/P2={node2,node3} healed via `igneum-inject addpeer` after 1/3/5/8 s forced selected-chain reorgs of depth 3, 6, 13, 11 on the losing node; all 3 nodes converged to one sink and agreed on the state root at the common height each time; the tx executed on the pre-heal chain re-resolved to one canonical, cross-node-consistent outcome (DAG merges the losing blocks, design 1.2/1.3; it does not orphan them); a fresh tx was mined after every reorg (mempool not stuck) | PASS (31/31 checks over 4 cycles) | +| 4 | Developer registry abuse | Design 4.5: base fees burned, no positive-expectation loop; record the max share a self-dealer recovers | register(someone-else's-contract) and register(unrelated EOA) both revert; a factory's CREATE and CREATE2 children inherit the factory payee; a same-tx creator override sets a different payee; an EOA cannot override a factory child; an unregistered factory's child has no payee (share burns); self-dealer (sender = payee = block miner) recovered 100.0% of the tip but only 56.45% of total fees paid, because both base fees are burned; recovered < paid always | PASS (19/19). Max share a self-dealer recovers: 56.45% of fees paid (tip only; base fees always lost) | + +Totals: 98 checks, 0 failures, 0 node panics, memory bounded. Execution time per block under the pgas attack stayed single-digit to low-tens of milliseconds (1.3 to 10.9 ms) at these loop sizes; the whole-account state-root recompute (design 10.3 item 1) dominates and will fall once the incremental trie lands. + +Findings (not consensus failures; filed for the ledger): +- F-exec-A (low): the EVM mempool admits a transaction whose `gas_limit` exceeds the block execution limit `B_e`. `igneum/exec/src/pool.rs` `EvmPool::add` checks funds, nonce and fee cap but never bounds `gas_limit` by `BLOCK_EXECUTION_GAS_LIMIT`. Reproduction: fund an account, send a type-2 tx with `gas=31_000_000` (B_e is 30,000,000) to any node's eth RPC; `eth_sendRawTransaction` returns a hash (admitted). The transaction can never be selected (`EvmPool::select` breaks when `gas + gas_limit > B_e`) nor form a valid block (`check_evm_body` -> `SumGasLimitAboveBlockLimit`), so it occupies a queue slot until evicted. Self-limited because admission still reserves `gas_limit x max_fee_per_gas` in the funds check. Fix: reject `gas_limit > B_e` in `EvmPool::add`, as geth rejects `gas > block gas limit`. +- F-exec-B (medium, griefing): an over-pgas-budget transaction is executed natively in full before it is skipped, and because it is skipped it pays no fee. A transaction whose own pgas exceeds B_p (for example one large modexp, or the 9,000-iter loop above at 30.96 M pgas) is included, executed (10.85 ms of real work here, more for a bigger input), then dropped with `BlockProvingBudget` and charged nothing (`igneum/exec/src/executor.rs`: the skip happens after `inspect_one_tx` runs and before any fee is taken). Every node re-executes it on every inclusion for free, and because the nonce never advances it also head-of-line-blocks that sender's higher nonces (seen here: the 14,000 and 20,000 loops were never includable behind the stuck 9,000). The funds check at admission does not bound pgas (pgas is not known without execution), so a modestly funded account can force repeated free computation network-wide. Fix options: charge the intrinsic plus consumed pgas on a budget skip, cap single-transaction pgas at admission via `eth_estimateGas`-style simulation, or drop a sender's queue on a `BlockProvingBudget` skip rather than retrying. + +Not covered here (out of scope for this pass, and because the proving layer is not implemented on this branch): proof records, the native-execution veto, sortition, and the finality lock (`proven`/`locked` are always false on devnet v3, so only `executed` was exercised). These need the proving layer and the finality merge (design 10.4) before they can be attacked. diff --git a/tools/exec-attacks/.gitignore b/tools/exec-attacks/.gitignore new file mode 100644 index 000000000..2be94185b --- /dev/null +++ b/tools/exec-attacks/.gitignore @@ -0,0 +1,2 @@ +node_modules +results/ diff --git a/tools/exec-attacks/README.md b/tools/exec-attacks/README.md new file mode 100644 index 000000000..f4d2e10b9 --- /dev/null +++ b/tools/exec-attacks/README.md @@ -0,0 +1,61 @@ +# Execution-layer attacks (robustness and conformance) + +Adversarial tests of the Igneum execution layer (`docs/design/execution-layer.md`, `docs/spec/07-execution.md`) +against a throwaway 3-node `igneumd` simnet. Each scenario is a runnable command with a pass criterion taken from +the design and a measured result. This is testing of our own private software. + +Everything runs on ports 27600 and above under `/tmp/igneum-exec-attacks`. The live devnet (26610, 26611, 26640, +26641, 28640) and other agents' ports (up to 27599) are never touched. + +## Build + +The node, the honest miner and the hostile injector are built in the worktree `vendor/igneum-node-exec-attacks` +(branch `exec-attacks`): + +``` +cd vendor/igneum-node-exec-attacks +export PATH="$HOME/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH" +CARGO_TARGET_DIR=target nice -n 19 cargo build --release -j 4 -p kaspad -p igneum-miner --features igneum-pow +``` + +This produces `igneumd`, `igneum-miner` and `igneum-inject` under `target/release`. + +`igneum-inject` is the hostile miner: it fetches a block template over gRPC, replaces the EVM body with an +arbitrary set of raw EIP-2718 bytes (which the mempool would never hand out), recomputes `hash_merkle_root` and +resubmits, so transactions the mempool rejects reach consensus body validation and the executor directly. Several +blocks built off one template share a selected parent and land in parallel on the DAG. + +## Contracts + +`node compile.mjs` compiles `contracts/PgasBomb.sol` (modexp/keccak loops, cheap in gas and heavy in pgas) and +`contracts/RegistryAbuse.sol` (a Worker and a Factory for the developer-registry tests) with solc 0.8.37. + +## Network + +``` +./net.sh start [1|3] # hub topology: 3 nodes, 1 or 3 honest stub miners +./net.sh start-split # partition P1={node1}, P2={node2,node3}, no link until heal (igneum-inject addpeer) +./net.sh stop +``` + +Nodes run `--simnet --enable-unsynced-mining --unsaferpc` (PoW skipped). eth JSON-RPC on 27690/27691/27692, gRPC on +27610/27620/27630, p2p on 27611/27621/27631. Node 1's miner pays the test `miner` account; nodes 2 and 3 pay the +test accounts B and C, so rewards are spendable by the harness whichever chain wins. + +## Scenarios (run in priority order 1, 2, 5, 3, 6, 4) + +| # | Command | What it does | Criterion | +|---|---|---|---| +| 1 | `node scenario1_malformed.mjs` | malformed and boundary txs over `eth_sendRawTransaction` and inside a hostile block (bad RLP, wrong chain id, oversized calldata, gas at/over the block limit, bad signature, nonce far ahead, nonce reuse, zero/max fee) | state-free faults invalidate the block; state-dependent faults skip the tx with no receipt; no panic; RSS bounded | +| 2 | `node scenario2_nonce.mjs` | one sender's nonces spread across parallel blocks in different orders, duplicates in several blocks, a conflicting same-nonce pair | exactly one execution per nonce; deterministic; state roots identical on all nodes | +| 5 | `node scenario5_rpcfuzz.mjs` | every `eth_*`/`igneum_*` with junk params, huge arrays, deep nesting; 50x `eth_call` flood from one client | errors not crashes; honest latency under 200 ms | +| 3 | `node scenario3_pgas.mjs` | modexp loops cheap in gas, heavy in pgas, with growing loop counts | the per-block pgas budget `B_p` caps inclusion; no executed block exceeds `B_p`; execution time per block measured | +| 6 | `./run_scenario6.sh` | partition/heal reorgs of several depths with hostile miners while txs flow (uses `start-split` and `igneum-inject addpeer`) | state root recomputed deterministically; displaced-tx receipts consistent on all nodes and canonical; no stuck mempool | +| 4 | `node scenario4_registry.mjs` | register a payee for someone else's code; factory inheritance (CREATE, CREATE2, same-tx override, unregistered, EOA override); self-dealing (sender = payee = miner) | design 4.5: base fees burned, no positive-expectation loop; records the max share a self-dealer recovers | + +`run_all.sh` runs every scenario in priority order (starting and stopping the right network for each) and prints a +one-line pass/fail per scenario. Per-scenario detail lands in `results/*.json`. + +Notes on DAG semantics observed here: a selected-chain reorg does not orphan merged blocks (design 1.2/1.3), so a +"displaced" transaction re-executes exactly once in the segment that merges its block rather than losing its +receipt; scenario 6 checks for a consistent, canonical outcome across nodes rather than Ethereum-style eviction. diff --git a/tools/exec-attacks/compile.mjs b/tools/exec-attacks/compile.mjs new file mode 100644 index 000000000..014aaea80 --- /dev/null +++ b/tools/exec-attacks/compile.mjs @@ -0,0 +1,25 @@ +// Compiles the attack contracts with solc-js and writes contracts/*.json (abi + creation bytecode). +import solc from 'solc'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +const here = path.dirname(fileURLToPath(import.meta.url)); +const input = { + language: 'Solidity', + sources: { + 'PgasBomb.sol': { content: readFileSync(path.join(here, 'contracts/PgasBomb.sol'), 'utf8') }, + 'RegistryAbuse.sol': { content: readFileSync(path.join(here, 'contracts/RegistryAbuse.sol'), 'utf8') }, + }, + settings: { optimizer: { enabled: true, runs: 200 }, evmVersion: 'cancun', outputSelection: { '*': { '*': ['abi', 'evm.bytecode.object'] } } }, +}; +const out = JSON.parse(solc.compile(JSON.stringify(input))); +for (const e of out.errors ?? []) { if (e.severity === 'error') { console.error(e.formattedMessage); process.exit(1); } } +const write = (file, srcFile, name) => { + const c = out.contracts[srcFile][name]; + writeFileSync(path.join(here, 'contracts', file), JSON.stringify({ abi: c.abi, bytecode: '0x' + c.evm.bytecode.object }, null, 1) + '\n'); + console.log(file, 'creation bytes', c.evm.bytecode.object.length / 2); +}; +write('PgasBomb.json', 'PgasBomb.sol', 'PgasBomb'); +write('Worker.json', 'RegistryAbuse.sol', 'Worker'); +write('Factory.json', 'RegistryAbuse.sol', 'Factory'); +console.log('solc', solc.version()); diff --git a/tools/exec-attacks/contracts/Factory.json b/tools/exec-attacks/contracts/Factory.json new file mode 100644 index 000000000..789a7268f --- /dev/null +++ b/tools/exec-attacks/contracts/Factory.json @@ -0,0 +1,82 @@ +{ + "abi": [ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "child", + "type": "address" + } + ], + "name": "Created", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "payee", + "type": "address" + } + ], + "name": "createAndOverride", + "outputs": [ + { + "internalType": "address", + "name": "c", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "createChild", + "outputs": [ + { + "internalType": "address", + "name": "c", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + } + ], + "name": "createChild2", + "outputs": [ + { + "internalType": "address", + "name": "c", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "payee", + "type": "address" + } + ], + "name": "registerSelf", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x6080604052348015600e575f5ffd5b5061044c8061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061004a575f3560e01c80630adab9911461004e5780639bcd8aca14610063578063a3a4ba3114610092578063abaf73a01461009a575b5f5ffd5b61006161005c3660046102b5565b6100ad565b005b6100766100713660046102b5565b61010c565b6040516001600160a01b03909116815260200160405180910390f35b6100766101d7565b6100766100a83660046102e2565b610240565b604051632a99dcd560e21b81523060048201526001600160a01b03821660248201526102109063aa677354906044015f604051808303815f87803b1580156100f3575f5ffd5b505af1158015610105573d5f5f3e3d5ffd5b5050505050565b5f604051610119906102a8565b604051809103905ff080158015610132573d5f5f3e3d5ffd5b50604051632a99dcd560e21b81526001600160a01b038083166004830152841660248201529091506102109063aa677354906044015f604051808303815f87803b15801561017e575f5ffd5b505af1158015610190573d5f5f3e3d5ffd5b50506040516001600160a01b03841681527f1449abf21e49fd025f33495e77f7b1461caefdd3d4bb646424a3f445c4576a5b925060200190505b60405180910390a1919050565b5f6040516101e4906102a8565b604051809103905ff0801580156101fd573d5f5f3e3d5ffd5b506040516001600160a01b03821681529091507f1449abf21e49fd025f33495e77f7b1461caefdd3d4bb646424a3f445c4576a5b9060200160405180910390a190565b5f8160405161024e906102a8565b8190604051809103905ff590508015801561026b573d5f5f3e3d5ffd5b506040516001600160a01b03821681529091507f1449abf21e49fd025f33495e77f7b1461caefdd3d4bb646424a3f445c4576a5b906020016101ca565b61011d806102fa83390190565b5f602082840312156102c5575f5ffd5b81356001600160a01b03811681146102db575f5ffd5b9392505050565b5f602082840312156102f2575f5ffd5b503591905056fe6080604052348015600e575f5ffd5b506101018061001c5f395ff3fe6080604052348015600e575f5ffd5b50600436106030575f3560e01c80635858d1611460345780637da1365e146055575b5f5ffd5b6043603f3660046091565b605c565b60405190815260200160405180910390f35b60435f5481565b5f805b82811015608857606f81600160a7565b5f5f828254607c919060a7565b9091555050600101605f565b50505f54919050565b5f6020828403121560a0575f5ffd5b5035919050565b8082018082111560c557634e487b7160e01b5f52601160045260245ffd5b9291505056fea2646970667358221220e49f86917e5122d124cf58c2751b666006f1a6e6efe04f0dd9293c1ff817a56664736f6c63430008250033a2646970667358221220a50c661aeb24f588e5c63a5b308e8af303f5cc4baf9039f747d6153d32acc4a464736f6c63430008250033" +} diff --git a/tools/exec-attacks/contracts/PgasBomb.json b/tools/exec-attacks/contracts/PgasBomb.json new file mode 100644 index 000000000..dae5129be --- /dev/null +++ b/tools/exec-attacks/contracts/PgasBomb.json @@ -0,0 +1,62 @@ +{ + "abi": [ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "iterations", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "acc", + "type": "uint256" + } + ], + "name": "Done", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "n", + "type": "uint256" + } + ], + "name": "keccakLoop", + "outputs": [ + { + "internalType": "bytes32", + "name": "h", + "type": "bytes32" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "n", + "type": "uint256" + } + ], + "name": "modexpLoop", + "outputs": [ + { + "internalType": "uint256", + "name": "acc", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x6080604052348015600e575f5ffd5b506103a08061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806322afaa4e14610038578063dc9f0cb31461005d575b5f5ffd5b61004b61004636600461031a565b610070565b60405190815260200160405180910390f35b61004b61006b36600461031a565b6100d0565b604080514360208083019190915282518083038201815291830190925280519101205f5b828110156100ca5760408051602081018490520160408051601f1981840301815291905280516020909101209150600101610094565b50919050565b6040805160e280825261012082019092525f91829190602082018180368337019050509050600160f81b81601f8151811061010d5761010d610331565b60200101906001600160f81b03191690815f1a905350600160f81b81603f8151811061013b5761013b610331565b60200101906001600160f81b03191690815f1a905350608060f81b81605f8151811061016957610169610331565b60200101906001600160f81b03191690815f1a905350600260f81b8160608151811061019757610197610331565b60200101906001600160f81b03191690815f1a905350600160f81b816061815181106101c5576101c5610331565b60200101906001600160f81b03191690815f1a9053505f5b6080811015610229576001600160f81b0319826101fb836062610345565b8151811061020b5761020b610331565b60200101906001600160f81b03191690815f1a9053506001016101dd565b5060408051608080825260a082019092525f916020820181803683370190505090505f5b848110156102d9575f60806020840185516020870160055afa9050806102a95760405162461bcd60e51b815260206004820152600d60248201526c1b5bd9195e1c0819985a5b1959609a1b604482015260640160405180910390fd5b82607f815181106102bc576102bc610331565b01602001516102ce9060f81c86610345565b94505060010161024d565b5060408051858152602081018590527f2006d76973ad24d89bba111e205a6594eb6ed9ed5bf0b64862bf758fed1445b9910160405180910390a15050919050565b5f6020828403121561032a575f5ffd5b5035919050565b634e487b7160e01b5f52603260045260245ffd5b8082018082111561036457634e487b7160e01b5f52601160045260245ffd5b9291505056fea2646970667358221220a99d434cf434c37014c8aa77d4d149d70b8d9fa45edd81e95ff4fd768fcec9cf64736f6c63430008250033" +} diff --git a/tools/exec-attacks/contracts/PgasBomb.sol b/tools/exec-attacks/contracts/PgasBomb.sol new file mode 100644 index 000000000..9038164d1 --- /dev/null +++ b/tools/exec-attacks/contracts/PgasBomb.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: ISC +pragma solidity ^0.8.20; + +// A contract that is cheap in execution gas but heavy in proving gas (pgas): it hammers the modexp precompile +// (0x05) and the KECCAK256 opcode in a loop. Under Igneum's pgas table (igneum/exec/src/pgas.rs) modexp is +// ~1000 + 10*input_len pgas for ~200 execution gas, so each call carries a large pgas/gas ratio and a loop can +// drive a transaction's pgas toward the per-block proving budget B_p with only a few million execution gas. +contract PgasBomb { + event Done(uint256 iterations, uint256 acc); + + // Call the 1024-bit modexp precompile `n` times. Each call: baseLen=1, expLen=1, modLen=128 (input_len 226). + function modexpLoop(uint256 n) external returns (uint256 acc) { + bytes memory input = new bytes(96 + 1 + 1 + 128); + // baseLen = 1 + input[31] = 0x01; + // expLen = 1 + input[63] = 0x01; + // modLen = 128 + input[95] = 0x80; + // base byte = 2 + input[96] = 0x02; + // exp byte = 1 + input[97] = 0x01; + // modulus: all 0xff (a large odd number) + for (uint256 i = 0; i < 128; i++) input[98 + i] = 0xff; + + bytes memory out = new bytes(128); + for (uint256 i = 0; i < n; i++) { + bool ok; + assembly { + ok := staticcall(gas(), 0x05, add(input, 32), mload(input), add(out, 32), 128) + } + require(ok, "modexp failed"); + acc += uint8(out[127]); + } + emit Done(n, acc); + } + + // Hash a 32-byte word `n` times, chaining the result. Pure KECCAK256 opcode load. + function keccakLoop(uint256 n) external returns (bytes32 h) { + h = keccak256(abi.encodePacked(block.number)); + for (uint256 i = 0; i < n; i++) { + h = keccak256(abi.encodePacked(h)); + } + } +} diff --git a/tools/exec-attacks/contracts/RegistryAbuse.sol b/tools/exec-attacks/contracts/RegistryAbuse.sol new file mode 100644 index 000000000..fd92b988c --- /dev/null +++ b/tools/exec-attacks/contracts/RegistryAbuse.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: ISC +pragma solidity ^0.8.20; + +// Contracts for scenario 4 (developer-registry abuse, design 4.5): a Worker whose calls spend gas (so a tip is +// attributable to its code), and a Factory that deploys Workers via CREATE and CREATE2 and exercises the registry's +// register rules, including a same-transaction override by the creator. + +interface IReg { + function register(address account, address payee) external; + function payeeOf(address account) external view returns (address); + function creatorOf(address account) external view returns (address); +} + +contract Worker { + uint256 public acc; + function work(uint256 n) external returns (uint256) { + for (uint256 i = 0; i < n; i++) acc += i + 1; + return acc; + } +} + +contract Factory { + address constant REG = 0x0000000000000000000000000000000000000210; + event Created(address child); + + // The factory registers itself (allowed: msg.sender == account). + function registerSelf(address payee) external { IReg(REG).register(address(this), payee); } + + // CREATE: child inherits the factory's payee by the executor's rule. + function createChild() external returns (address c) { c = address(new Worker()); emit Created(c); } + + // CREATE2: same inheritance. + function createChild2(bytes32 salt) external returns (address c) { c = address(new Worker{salt: salt}()); emit Created(c); } + + // The creating transaction overrides the child's payee in the same tx (allowed: factory is the recorded creator). + function createAndOverride(address payee) external returns (address c) { + c = address(new Worker()); + IReg(REG).register(c, payee); + emit Created(c); + } +} diff --git a/tools/exec-attacks/contracts/Worker.json b/tools/exec-attacks/contracts/Worker.json new file mode 100644 index 000000000..d00582c7c --- /dev/null +++ b/tools/exec-attacks/contracts/Worker.json @@ -0,0 +1,37 @@ +{ + "abi": [ + { + "inputs": [], + "name": "acc", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "n", + "type": "uint256" + } + ], + "name": "work", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x6080604052348015600e575f5ffd5b506101018061001c5f395ff3fe6080604052348015600e575f5ffd5b50600436106030575f3560e01c80635858d1611460345780637da1365e146055575b5f5ffd5b6043603f3660046091565b605c565b60405190815260200160405180910390f35b60435f5481565b5f805b82811015608857606f81600160a7565b5f5f828254607c919060a7565b9091555050600101605f565b50505f54919050565b5f6020828403121560a0575f5ffd5b5035919050565b8082018082111560c557634e487b7160e01b5f52601160045260245ffd5b9291505056fea2646970667358221220e49f86917e5122d124cf58c2751b666006f1a6e6efe04f0dd9293c1ff817a56664736f6c63430008250033" +} diff --git a/tools/exec-attacks/lib/common.mjs b/tools/exec-attacks/lib/common.mjs new file mode 100644 index 000000000..f3aadcb0f --- /dev/null +++ b/tools/exec-attacks/lib/common.mjs @@ -0,0 +1,144 @@ +// 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 []; } +} diff --git a/tools/exec-attacks/net.sh b/tools/exec-attacks/net.sh new file mode 100755 index 000000000..25337d3a1 --- /dev/null +++ b/tools/exec-attacks/net.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Starts a throwaway 3-node igneumd simnet (PoW skipped) for the execution-layer attacks, on ports 27600 and +# above, data under /tmp/igneum-exec-attacks, with one honest stub miner on node 1 by default. The live devnet +# (26610, 26611, 26640, 26641, 28640) and other agents' ports (up to 27599) are never touched. +# +# ./net.sh start [miners] miners = 1 (default) or 3 +# ./net.sh stop +# ./net.sh grpc1 prints node 1's gRPC url (for igneum-inject) +# +# Node i (i=1..3): gRPC 276{i}0, p2p 276{i}1, eth RPC 2769{i-1}. Node 1 gRPC 27610, eth 27690. +set -u +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BIN="$ROOT/vendor/igneum-node-exec-attacks/target/release" +DATA=/tmp/igneum-exec-attacks +PIDS="$DATA/pids" +DURATION="${DURATION:-1200}" +HOLD_MS="${HOLD_MS:-1000}" +MINER1_VKH=0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8 +MINER2_VKH=0x00000000000000000000000015d34aaf54267db7d7c367839aaf71a00a2c6a65 +MINER3_VKH=0x00000000000000000000000001d47ee52ebca6d0b0beac0f124acde471e0968a + +grpc1() { echo "grpc://127.0.0.1:27610"; } +grpc2() { echo "grpc://127.0.0.1:27620"; } +grpc3() { echo "grpc://127.0.0.1:27630"; } + +start_node() { + local i=$1 connect=$2 + local grpc=276${i}0 p2p=276${i}1 eth=2769$((i-1)) + local dir="$DATA/n$i" + mkdir -p "$dir" + local extra="" + [ -n "$connect" ] && extra="--connect=$connect" + "$BIN/igneumd" --simnet --utxoindex --loglevel=info --disable-upnp --enable-unsynced-mining --unsaferpc \ + --appdir="$dir" \ + --rpclisten=0.0.0.0:$grpc \ + --listen=0.0.0.0:$p2p \ + --evm-rpclisten=127.0.0.1:$eth \ + $extra > "$dir/node.log" 2>&1 & + echo $! >> "$PIDS" +} + +# Block until a node's gRPC server accepts connections (log line), up to ~40 s. +wait_grpc() { + local dir=$1 t=0 + until grep -q "GRPC Server starting on" "$dir/node.log" 2>/dev/null || [ $t -ge 40 ]; do sleep 1; t=$((t+1)); done + sleep 2 +} + +start_miner() { + local grpc=$1 vkh=$2 label=$3 + "$BIN/igneum-miner" mine "$grpc" 1 "$DURATION" "$label" \ + --engine stub --hold-ms "$HOLD_MS" --network simnet --vote-key-hash "$vkh" \ + > "$DATA/miner_$label.log" 2>&1 & + echo $! >> "$PIDS" +} + +case "${1:-}" in + start) + miners="${2:-1}" + rm -rf "$DATA"; mkdir -p "$DATA"; : > "$PIDS" + start_node 1 "" + start_node 2 "127.0.0.1:27611" + start_node 3 "127.0.0.1:27611" + wait_grpc "$DATA/n1"; wait_grpc "$DATA/n2"; wait_grpc "$DATA/n3" + start_miner "$(grpc1)" "$MINER1_VKH" node1 + if [ "$miners" = "3" ]; then + start_miner "$(grpc2)" "$MINER2_VKH" node2 + start_miner "$(grpc3)" "$MINER3_VKH" node3 + fi + echo "started $miners miner(s); data $DATA; eth RPCs 27690 27691 27692" + ;; + start-split) + # Partition P1 = {node1}, P2 = {node2, node3}. No link between the partitions until heal (igneum-inject addpeer). + rm -rf "$DATA"; mkdir -p "$DATA"; : > "$PIDS" + start_node 1 "" + start_node 2 "" + start_node 3 "127.0.0.1:27621" + wait_grpc "$DATA/n1"; wait_grpc "$DATA/n2"; wait_grpc "$DATA/n3" + start_miner "$(grpc1)" "$MINER1_VKH" node1 + start_miner "$(grpc2)" "$MINER2_VKH" node2 + start_miner "$(grpc3)" "$MINER3_VKH" node3 + echo "started split: P1={node1}, P2={node2,node3}; eth RPCs 27690 27691 27692" + ;; + stop) + if [ -f "$PIDS" ]; then + while read -r p; do [ -n "$p" ] && kill "$p" 2>/dev/null; done < "$PIDS" + sleep 1 + while read -r p; do [ -n "$p" ] && kill -9 "$p" 2>/dev/null; done < "$PIDS" + fi + pkill -f "igneum-node-exec-attacks/target/release/igneumd --simnet" 2>/dev/null + pkill -f "igneum-miner mine grpc://127.0.0.1:276" 2>/dev/null + echo "stopped" + ;; + grpc1) grpc1 ;; + grpc2) grpc2 ;; + grpc3) grpc3 ;; + *) echo "usage: $0 start [miners] | stop | grpc1|grpc2|grpc3"; exit 2 ;; +esac diff --git a/tools/exec-attacks/run_all.sh b/tools/exec-attacks/run_all.sh new file mode 100755 index 000000000..77e0032d9 --- /dev/null +++ b/tools/exec-attacks/run_all.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Runs every execution-layer attack in priority order (1, 2, 5, 3, 6, 4), starting and stopping the right network +# for each, and prints a one-line pass/fail per scenario. Detail in results/*.json. +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE" +mkdir -p results + +wait_rpc() { local t=0; until [ "$(curl -s -m2 -X POST http://127.0.0.1:$1 -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' | grep -o '0x[0-9a-f]*' | head -1)" != "0x0" ] 2>/dev/null || [ $t -ge 40 ]; do sleep 2; t=$((t+2)); done; } + +echo "== build check ==" +test -x ../../vendor/igneum-node-exec-attacks/target/release/igneum-inject || { echo "build igneum-inject first (see README)"; exit 1; } +node compile.mjs >/dev/null + +# Scenarios 1, 2, 5, 3 run on the hub network with one honest miner. +./net.sh stop >/dev/null 2>&1 +./net.sh start 1 >/dev/null 2>&1 +wait_rpc 27690 +for s in scenario1_malformed scenario2_nonce scenario5_rpcfuzz scenario3_pgas; do + echo "== $s ==" + node $s.mjs | tail -1 +done +./net.sh stop >/dev/null 2>&1 + +# Scenario 6 manages its own split network across several partition depths. +echo "== scenario6_reorg ==" +./run_scenario6.sh | tail -2 + +# Scenario 4 needs the single-miner hub (sender = payee = miner for self-dealing). +./net.sh start 1 >/dev/null 2>&1 +wait_rpc 27690 +echo "== scenario4_registry ==" +node scenario4_registry.mjs | tail -1 +./net.sh stop >/dev/null 2>&1 + +echo "== done; see results/*.json ==" diff --git a/tools/exec-attacks/run_scenario6.sh b/tools/exec-attacks/run_scenario6.sh new file mode 100755 index 000000000..0eb033983 --- /dev/null +++ b/tools/exec-attacks/run_scenario6.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Runs scenario 6 (reorg under execution) for several partition durations, restarting the split network each time so +# each cycle forces a reorg of a different depth. Results accumulate in results/scenario6.json. +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE" +rm -f results/scenario6.json +DURS="${DURS:-1 3 5 8}" +for secs in $DURS; do + ./net.sh stop >/dev/null 2>&1 + ./net.sh start-split >/dev/null 2>&1 + # Wait for both partitions to produce a block. + t=0 + until [ "$(curl -s -m2 -X POST http://127.0.0.1:27691 -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' | grep -o '0x[0-9a-f]*' | head -1)" != "0x0" ] 2>/dev/null || [ $t -ge 40 ]; do sleep 2; t=$((t+2)); done + echo "=== cycle: partition ${secs}s ===" + PARTITION_SECS=$secs node scenario6_reorg.mjs +done +./net.sh stop >/dev/null 2>&1 +echo "=== scenario 6 depths ===" +node -e "const r=require('./results/scenario6.json'); console.log('cycles:', r.cycles.map(c=>({secs:c.partitionSecs, depth:c.maxReorgDepth, pass:c.summary.pass, fail:c.summary.fail}))); console.log('totals', r.checks, 'maxDepth', r.maxReorgDepthSeen)" diff --git a/tools/exec-attacks/scenario1_malformed.mjs b/tools/exec-attacks/scenario1_malformed.mjs new file mode 100644 index 000000000..60021a30f --- /dev/null +++ b/tools/exec-attacks/scenario1_malformed.mjs @@ -0,0 +1,161 @@ +// Scenario 1: malformed and boundary transactions, over eth_sendRawTransaction and inside blocks a hostile miner +// includes directly (igneum-inject). Design 1.5: state-free faults make the block invalid; state-dependent faults +// skip the transaction with no receipt and no fee; the node never panics; memory stays bounded. +// +// For each case we assert the mempool path (eth_sendRawTransaction) AND the direct-inclusion path (a hostile block) +// behave as the design says, then confirm the node is still producing blocks and its RSS has not run away. +import { toRlp, parseEther } from 'viem'; +import * as k from './lib/common.mjs'; + +const results = { scenario: '1-malformed-and-boundary', cases: [] }; +const checks = new k.Checks(); + +// Minimal big-endian hex for an RLP integer field ('0x' for zero). +function int(n) { + n = BigInt(n); + if (n === 0n) return '0x'; + let h = n.toString(16); + if (h.length % 2) h = '0' + h; + return '0x' + h; +} +// A type-2 transaction with an attacker-chosen signature (yParity, r, s). s=0 is an invalid signature. +function type2WithSig({ nonce, maxPrio, maxFee, gas, to, value = 0n, data = '0x' }, yParity, r, s) { + const body = toRlp([int(k.CHAIN_ID), int(nonce), int(maxPrio), int(maxFee), int(gas), to, int(value), data, [], int(yParity), int(r), int(s)]); + return '0x02' + body.slice(2); +} + +async function expectBlockInvalid(name, raw) { + // Mempool should reject with an error (not a crash); the hostile block should be rejected by body validation. + const m = await k.send(k.node1, raw); + const rep = k.inject([[raw]])[0]; + const mempoolRejected = m.hash === null; + const blockRejected = rep.accepted === false; + checks.check(mempoolRejected, `${name}: mempool rejects (error: ${m.error ?? 'none'})`); + checks.check(blockRejected, `${name}: hostile block invalid (${rep.reject ?? rep.error ?? 'accepted!'})`); + results.cases.push({ name, class: 'state-free', mempoolRejected, blockRejected, mempoolError: m.error, blockReject: rep.reject ?? rep.error }); +} + +async function expectSkipped(name, rawList, hashesToCheck) { + // A hostile block with these txs must be ACCEPTED, but each named transaction gets no receipt (skipped). + const rep = k.inject([rawList])[0]; + const accepted = rep.accepted === true; + checks.check(accepted, `${name}: hostile block accepted (${rep.reject ?? rep.error ?? 'ok'})`); + await k.sleep(1500); + let allReceiptless = true; + for (const h of hashesToCheck) { + const r = await k.receiptOf(h); + const st = await k.rpc(k.node1, 'igneum_getTransactionStatus', [h]); + const receiptless = r === null && (!st || st.executed === false); + if (!receiptless) allReceiptless = false; + } + checks.check(allReceiptless, `${name}: skipped tx has no receipt, not executed`); + results.cases.push({ name, class: 'state-dependent', accepted, allReceiptless }); +} + +async function main() { + await k.waitTip(2); + await k.fund([k.A, k.B, k.C, k.D]); + const rssBefore = k.nodeRssKib(); + + // ---- state-free faults: block must be invalid ---- + await expectBlockInvalid('bad-rlp', '0x02deadbeef'); + await expectBlockInvalid('unsupported-type-3-blob', '0x03c0'); + await expectBlockInvalid('wrong-chain-id', await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, chainId: 4461 })); + await expectBlockInvalid('intrinsic-gas-above-limit', await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, data: '0x' + 'ab'.repeat(5000), gas: 21000n })); + await expectBlockInvalid('initcode-too-large', await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: null, data: '0x' + '60'.repeat(60000), gas: 20_000_000n })); + await expectBlockInvalid('invalid-signature-s-zero', type2WithSig({ nonce: 0, maxPrio: 1_000_000_000n, maxFee: 2_000_000_000n, gas: 21000n, to: k.B.address, value: 1n }, 0, 1n, 0n)); + + // Single gas limit above the block limit B_e: the hostile block is invalid (sum of gas limits > B_e, state-free). + // The mempool, however, has no gas-limit bound in pool.add, so it ADMITS such a transaction; it can never be + // selected (pool.select breaks on it) nor form a valid block. Recorded as an observation, not a consensus fault. + { + const raw = await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, gas: 31_000_000n, maxFeePerGas: 2_000_000_000n }); + const m = await k.send(k.node1, raw); + const rep = k.inject([[raw]])[0]; + checks.check(rep.accepted === false, `single-gas-limit-over-block: hostile block invalid (${rep.reject ?? rep.error})`); + results.cases.push({ name: 'single-gas-limit-over-block', class: 'state-free', blockRejected: rep.accepted === false, mempoolAdmitted: m.hash !== null, observation: m.hash !== null ? 'mempool admits a tx with gas_limit > B_e (pool.add has no gas-limit bound)' : null }); + } + + // Duplicate hash in one block, and same sender+nonce twice in one block (nonce not contiguous): block-level, + // so only the hostile-inclusion path applies (the mempool never offers these together). + { + const raw = await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, value: 1n }); + const rep = k.inject([[raw, raw]])[0]; + checks.check(rep.accepted === false, `duplicate-hash-in-block: invalid (${rep.reject ?? rep.error})`); + results.cases.push({ name: 'duplicate-hash-in-block', class: 'state-free', blockReject: rep.reject ?? rep.error }); + } + { + const n = await k.nonceOf(k.A); + const a = await k.signTx(k.A, { nonce: n, to: k.B.address, value: 1n }); + const b = await k.signTx(k.A, { nonce: n + 2, to: k.B.address, value: 2n }); // gap -> not contiguous + const rep = k.inject([[a, b]])[0]; + checks.check(rep.accepted === false, `nonces-not-contiguous-in-block: invalid (${rep.reject ?? rep.error})`); + results.cases.push({ name: 'nonces-not-contiguous-in-block', class: 'state-free', blockReject: rep.reject ?? rep.error }); + } + + const { keccak256 } = await import('viem'); + + // ---- boundary: gas limit exactly at the block limit is NOT a body fault; the hostile block is accepted and the + // transaction executes (actual gas used is 21000). Injected so the mempool's own checks do not get in the way. + { + const raw = await k.signTx(k.B, { nonce: await k.nonceOf(k.B), to: k.C.address, value: 1n, gas: 30_000_000n, maxFeePerGas: 2_000_000_000n }); + const rep = k.inject([[raw]])[0]; + checks.check(rep.accepted === true, `gas-limit-at-block-limit: block accepted, not a body fault (${rep.reject ?? rep.error ?? 'accepted'})`); + const r = await k.waitReceipt(keccak256(raw), 20_000); + checks.check(r !== null, 'gas-limit-at-block-limit: transaction executed (has receipt)'); + results.cases.push({ name: 'gas-limit-at-block-limit', class: 'boundary', accepted: rep.accepted, executed: r !== null }); + } + + // ---- state-dependent faults: block accepted, tx skipped, no receipt. Each uses a distinct sender so an earlier + // case never leaves a queued transaction at the nonce a later case reuses. ---- + { + const n = await k.nonceOf(k.C); + const raw = await k.signTx(k.C, { nonce: n + 50, to: k.B.address, value: 1n }); // nonce far ahead + await expectSkipped('nonce-far-ahead', [raw], [keccak256(raw)]); + } + { + // Reuse a nonce that has already executed: land one through the mempool, then inject the same nonce again. + const n = await k.nonceOf(k.D); + const first = await k.signTx(k.D, { nonce: n, to: k.B.address, value: 1n }); + const fr = await k.send(k.node1, first); + const got = await k.waitReceipt(fr.hash); + checks.check(got !== null, 'nonce-reuse: the first copy executed through the mempool'); + const reuse = await k.signTx(k.D, { nonce: n, to: k.C.address, value: 7n }); + await expectSkipped('nonce-reuse', [reuse], [keccak256(reuse)]); + } + { + const n = await k.nonceOf(k.C); + const zeroFee = await k.signTx(k.C, { nonce: n, to: k.B.address, value: 1n, maxFeePerGas: 0n, maxPriorityFeePerGas: 0n }); + await expectSkipped('zero-fee-below-base', [zeroFee], [keccak256(zeroFee)]); + } + { + const n = await k.nonceOf(k.C); + const broke = await k.signTx(k.C, { nonce: n, to: k.B.address, value: parseEther('1000000'), gas: 21000n }); // more than balance + await expectSkipped('insufficient-funds', [broke], [keccak256(broke)]); + } + { + // Max fee at a u128-scale extreme: the wei budget exceeds the balance, so the tx is skipped, no crash. + const n = await k.nonceOf(k.C); + const big = await k.signTx(k.C, { nonce: n, to: k.B.address, value: 1n, maxFeePerGas: (1n << 120n), maxPriorityFeePerGas: 1n, gas: 21000n }); + await expectSkipped('max-fee-extreme', [big], [keccak256(big)]); + } + + // ---- liveness and memory after the barrage ---- + const tipBefore = await k.rpc(k.node1, 'eth_blockNumber'); + await k.sleep(4000); + const tipAfter = await k.rpc(k.node1, 'eth_blockNumber'); + checks.check(BigInt(tipAfter) > BigInt(tipBefore), `node still produces blocks after the barrage (${tipBefore} -> ${tipAfter})`); + const rssAfter = k.nodeRssKib(); + const maxAfter = Math.max(0, ...rssAfter); + const maxBefore = Math.max(1, ...rssBefore); + const growth = maxAfter / maxBefore; + checks.check(growth < 1.5, `node RSS bounded (max ${maxBefore} -> ${maxAfter} KiB, x${growth.toFixed(2)})`); + results.rss = { beforeKib: rssBefore, afterKib: rssAfter }; + + const s = checks.summary('scenario 1'); + results.summary = s; + const { writeFileSync } = await import('node:fs'); + writeFileSync(new URL('./results/scenario1.json', import.meta.url), JSON.stringify(results, null, 2)); + process.exit(s.ok ? 0 : 1); +} +main().catch((e) => { console.error(e); process.exit(2); }); diff --git a/tools/exec-attacks/scenario2_nonce.mjs b/tools/exec-attacks/scenario2_nonce.mjs new file mode 100644 index 000000000..976dc6ade --- /dev/null +++ b/tools/exec-attacks/scenario2_nonce.mjs @@ -0,0 +1,90 @@ +// Scenario 2: nonce games across parallel blocks. One sender's transactions with nonces n..n+k are spread across +// several parallel blocks (same selected parent) in different orders, with duplicates in several blocks and a +// conflicting same-nonce pair. Design 1.3/1.4: exactly one execution per nonce, deterministic across all nodes, +// state roots identical. The parallel blocks are built with igneum-inject off one template so they truly share a +// parent; the honest node-1 miner then merges them and the executor orders the mergeset. +import { keccak256 } from 'viem'; +import * as k from './lib/common.mjs'; + +const results = { scenario: '2-nonce-games', rounds: [] }; +const checks = new k.Checks(); + +async function allNodesAgreeAt(height, timeoutMs = 30_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const roots = []; + let ok = true; + for (const c of k.clients) { + const b = await k.rpc(c, 'eth_getBlockByNumber', ['0x' + height.toString(16), false]).catch(() => null); + if (!b) { ok = false; break; } + roots.push(b.stateRoot); + } + if (ok && roots.every((r) => r === roots[0])) return { agree: true, root: roots[0], roots }; + await k.sleep(500); + } + // one last read for the report + const roots = []; + for (const c of k.clients) { const b = await k.rpc(c, 'eth_getBlockByNumber', ['0x' + height.toString(16), false]).catch(() => null); roots.push(b?.stateRoot ?? null); } + return { agree: roots.every((r) => r === roots[0] && r !== null), root: roots[0], roots }; +} + +async function receiptCount(hashes) { + let n = 0; + for (const h of hashes) if (await k.rpc(k.node1, 'eth_getTransactionReceipt', [h])) n++; + return n; +} + +async function main() { + await k.waitTip(2); + await k.fund([k.A], '200'); + + // ---- Round 1: ordering + duplicates across parallel blocks ---- + let n = await k.nonceOf(k.A); + const txs = []; + for (let i = 0; i < 4; i++) txs.push(await k.signTx(k.A, { nonce: n + i, to: k.B.address, value: BigInt(100 + i) })); + const hashes = txs.map(keccak256); + // Parallel blocks, each a contiguous run from nonce n, with heavy duplication: + const reports = k.inject([ + [txs[0], txs[1], txs[2], txs[3]], + [txs[0], txs[1]], + [txs[0]], + ]); + const allAccepted = reports.every((r) => r.accepted === true); + checks.check(allAccepted, `round1: 3 parallel blocks accepted (${reports.map((r) => r.accepted).join(',')})`); + // Wait for the honest miner to merge them and execute the segment. + await k.sleep(5000); + const finalNonce = await k.nonceOf(k.A); + checks.check(finalNonce === n + 4, `round1: account nonce advanced to n+4 (n=${n}, got ${finalNonce})`); + let perNonceOk = true; + for (let i = 0; i < 4; i++) { const c = await receiptCount([hashes[i]]); if (c !== 1) { perNonceOk = false; console.error(` nonce ${n + i}: ${c} receipts`); } } + checks.check(perNonceOk, 'round1: each nonce executed exactly once (one receipt per unique tx)'); + const tip1 = Number(await k.rpc(k.node1, 'eth_blockNumber')); + const agree1 = await allNodesAgreeAt(tip1); + checks.check(agree1.agree, `round1: state roots identical on all nodes at block ${tip1} (${agree1.root})`); + results.rounds.push({ round: 1, allAccepted, finalNonce, expectedNonce: n + 4, perNonceOk, tip: tip1, rootsAgree: agree1.agree, root: agree1.root }); + + // ---- Round 2: conflicting same-nonce pair in two parallel blocks ---- + const m = await k.nonceOf(k.A); + const u0 = await k.signTx(k.A, { nonce: m, to: k.B.address, value: 1_000n }); + const u0b = await k.signTx(k.A, { nonce: m, to: k.C.address, value: 9_999n }); // same nonce, different content -> different hash + const h0 = keccak256(u0), h0b = keccak256(u0b); + checks.check(h0 !== h0b, 'round2: conflicting pair has distinct hashes'); + const rep2 = k.inject([[u0], [u0b]]); + checks.check(rep2.every((r) => r.accepted), `round2: both parallel blocks accepted (${rep2.map((r) => r.accepted).join(',')})`); + await k.sleep(5000); + const cnt = await receiptCount([h0, h0b]); + checks.check(cnt === 1, `round2: exactly one of the conflicting pair executed (${cnt} receipts)`); + const nonce2 = await k.nonceOf(k.A); + checks.check(nonce2 === m + 1, `round2: nonce advanced by exactly one (m=${m}, got ${nonce2})`); + const tip2 = Number(await k.rpc(k.node1, 'eth_blockNumber')); + const agree2 = await allNodesAgreeAt(tip2); + checks.check(agree2.agree, `round2: state roots identical on all nodes at block ${tip2} (${agree2.root})`); + results.rounds.push({ round: 2, bothAccepted: rep2.every((r) => r.accepted), executedCount: cnt, nonce: nonce2, expected: m + 1, rootsAgree: agree2.agree, root: agree2.root, which: (await k.rpc(k.node1, 'eth_getTransactionReceipt', [h0])) ? 'u0' : 'u0b' }); + + const s = checks.summary('scenario 2'); + results.summary = s; + const { writeFileSync } = await import('node:fs'); + writeFileSync(new URL('./results/scenario2.json', import.meta.url), JSON.stringify(results, null, 2)); + process.exit(s.ok ? 0 : 1); +} +main().catch((e) => { console.error(e); process.exit(2); }); diff --git a/tools/exec-attacks/scenario3_pgas.mjs b/tools/exec-attacks/scenario3_pgas.mjs new file mode 100644 index 000000000..e7f381ea4 --- /dev/null +++ b/tools/exec-attacks/scenario3_pgas.mjs @@ -0,0 +1,88 @@ +// Scenario 3: proving-gas (pgas) exhaustion. Transactions that are cheap in execution gas but heavy in pgas (loops +// of the modexp precompile) are sent with growing loop counts. Design 4.3: the per-block pgas budget B_p caps +// inclusion (an over-budget transaction is skipped at execution, no receipt, the block stays valid) and no executed +// block carries more than B_p pgas. We also measure execution time per block under the attack and note whether the +// template builder pre-filters by pgas. +import { encodeFunctionData, keccak256 } from 'viem'; +import { readFileSync } from 'node:fs'; +import * as k from './lib/common.mjs'; + +const art = JSON.parse(readFileSync(new URL('./contracts/PgasBomb.json', import.meta.url))); +const results = { scenario: '3-pgas-exhaustion', deploy: null, runs: [] }; +const checks = new k.Checks(); +let B_p = 30_000_000; + +async function statusOf(hash) { return k.rpc(k.node1, 'igneum_getTransactionStatus', [hash]); } +async function segment(n) { return k.rpc(k.node1, 'igneum_getSegment', ['0x' + n.toString(16)]); } + +async function main() { + await k.waitTip(2); + await k.fund([k.A], '20'); + const budgets = await k.rpc(k.node1, 'igneum_getBudgets'); + B_p = Number(BigInt(budgets.provingGasLimit)); + + // Deploy the pgas bomb. + let nonce = await k.nonceOf(k.A); + const deployRaw = await k.signTx(k.A, { nonce: nonce++, to: null, data: art.bytecode, gas: 500_000n, maxFeePerGas: 2_000_000_000n }); + const dsend = await k.send(k.node1, deployRaw); + const drcpt = await k.waitReceipt(dsend.hash, 30_000); + checks.check(drcpt && drcpt.contractAddress, `deployed PgasBomb (${drcpt?.contractAddress})`); + const bomb = drcpt.contractAddress; + results.deploy = { address: bomb, gasUsed: drcpt && parseInt(drcpt.gasUsed, 16) }; + + // Growing modexp loop counts: small ones execute (high pgas, under B_p), large ones exceed B_p and are skipped. + const counts = [1000, 3000, 6000, 9000, 14000, 20000]; + let sawExecuted = false, sawSkippedByBudget = false, maxExecutedPgas = 0; + for (const nIter of counts) { + const data = encodeFunctionData({ abi: art.abi, functionName: 'modexpLoop', args: [BigInt(nIter)] }); + const raw = await k.signTx(k.A, { nonce: nonce++, to: bomb, data, gas: 29_000_000n, maxFeePerGas: 2_000_000_000n }); + const hash = keccak256(raw); + const s = await k.send(k.node1, raw); + if (s.error) { results.runs.push({ nIter, mempoolError: s.error }); continue; } + // Wait for it to land (executed receipt) or be included-and-skipped. + let rec = null, st = null; + const start = Date.now(); + while (Date.now() - start < 30_000) { + rec = await k.receiptOf(hash); + st = await statusOf(hash); + if (rec || (st && st.includedIn && st.includedIn.length)) break; + await k.sleep(400); + } + const inc = st?.includedIn?.[0]; + let blockNum = rec ? parseInt(rec.blockNumber, 16) : inc?.chainBlockNumber ? parseInt(inc.chainBlockNumber, 16) : null; + let execMicros = null, blockPgas = null; + if (blockNum != null) { const seg = await segment(blockNum); execMicros = parseInt(seg.executionMicros, 16); blockPgas = parseInt(seg.pgasUsed, 16); } + if (rec) { + const pgas = parseInt(rec.pgasUsed ?? (await statusOf(hash)).pgasUsed ?? '0x0', 16); + const pg = rec.igneum ? parseInt(rec.igneum.pgasUsed, 16) : pgas; + sawExecuted = true; maxExecutedPgas = Math.max(maxExecutedPgas, pg); + results.runs.push({ nIter, outcome: 'executed', pgasUsed: pg, gasUsed: parseInt(rec.gasUsed, 16), blockNum, blockPgas, execMicros }); + } else { + const reason = inc?.skipReason ?? 'not-included'; + const budgetSkip = /BlockProvingBudget/i.test(reason); + if (budgetSkip) sawSkippedByBudget = true; + results.runs.push({ nIter, outcome: 'skipped', reason, blockNum, blockPgas, execMicros }); + } + } + + checks.check(sawExecuted, 'at least one pgas-heavy transaction executed under the budget'); + checks.check(sawSkippedByBudget, 'the per-block pgas budget caps inclusion: a heavy transaction is skipped with BlockProvingBudget'); + const executedBlocks = results.runs.filter((r) => r.outcome === 'executed' && r.blockPgas != null); + const overBudgetBlock = executedBlocks.find((r) => r.blockPgas > B_p); + checks.check(!overBudgetBlock, `no executed block carries more than B_p pgas (B_p=${B_p}, max executed block pgas=${Math.max(0, ...executedBlocks.map((r) => r.blockPgas))})`); + + // Execution time per block under the attack, for the bench log. + const micros = results.runs.map((r) => r.execMicros).filter((x) => x != null); + results.executionMicros = { perRun: results.runs.map((r) => ({ nIter: r.nIter, outcome: r.outcome, execMicros: r.execMicros, blockPgas: r.blockPgas })), max: Math.max(0, ...micros) }; + results.maxExecutedPgas = maxExecutedPgas; + results.B_p = B_p; + // Observation: whether a skipped (over-budget) transaction still forced full native execution for no fee. + results.observation = sawSkippedByBudget ? 'an over-budget transaction is executed natively in full, then skipped and charged no fee (free computation for the attacker, every node pays)' : null; + + const s = checks.summary('scenario 3'); + results.summary = s; + const { writeFileSync } = await import('node:fs'); + writeFileSync(new URL('./results/scenario3.json', import.meta.url), JSON.stringify(results, null, 2)); + process.exit(s.ok ? 0 : 1); +} +main().catch((e) => { console.error(e); process.exit(2); }); diff --git a/tools/exec-attacks/scenario4_registry.mjs b/tools/exec-attacks/scenario4_registry.mjs new file mode 100644 index 000000000..c23f6ea71 --- /dev/null +++ b/tools/exec-attacks/scenario4_registry.mjs @@ -0,0 +1,153 @@ +// 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); }); diff --git a/tools/exec-attacks/scenario5_rpcfuzz.mjs b/tools/exec-attacks/scenario5_rpcfuzz.mjs new file mode 100644 index 000000000..504deb48a --- /dev/null +++ b/tools/exec-attacks/scenario5_rpcfuzz.mjs @@ -0,0 +1,120 @@ +// 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); }); diff --git a/tools/exec-attacks/scenario6_reorg.mjs b/tools/exec-attacks/scenario6_reorg.mjs new file mode 100644 index 000000000..c5abf7ec1 --- /dev/null +++ b/tools/exec-attacks/scenario6_reorg.mjs @@ -0,0 +1,122 @@ +// Scenario 6: reorgs under execution. The network starts partitioned (P1 = node1, P2 = node2 + node3, two miners +// so P2 outweighs P1) and heals after PARTITION_SECS, forcing the lighter partition to reorg its whole chain while +// transactions flow. Design 1.2 (reorgs), 2.3 (executed can be undone), 10.2 (64-deep snapshots). Criterion: the +// state root is recomputed deterministically (all nodes agree), receipts for displaced transactions are handled as +// the design says (the displaced copy loses its receipt), and the mempool is not stuck afterwards. +import { keccak256 } from 'viem'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import * as k from './lib/common.mjs'; + +const SECS = Number(process.env.PARTITION_SECS ?? 10); +const [n1, n2, n3] = k.clients; +const checks = new k.Checks(); +const cycle = { partitionSecs: SECS }; + +const bn = (c) => k.rpc(c, 'eth_blockNumber').then((x) => Number(x)); +const blockAt = (c, h) => k.rpc(c, 'eth_getBlockByNumber', ['0x' + h.toString(16), false]).catch(() => null); +const budgets = (c) => k.rpc(c, 'igneum_getBudgets'); +function daginfoSink(grpc) { try { return JSON.parse(k.injectCmd(['daginfo'], grpc)).sink; } catch { return null; } } + +async function waitBalance(addr, min, client, ms = 60_000) { + const start = Date.now(); + while (Date.now() - start < ms) { if ((await k.balanceOf(addr, client)) >= min) return true; await k.sleep(1000); } + return false; +} + +async function main() { + // Wait for both partitions to be producing and clearly divergent (different hash at height 1). + const t0 = Date.now(); + while (Date.now() - t0 < 60_000) { try { if ((await bn(n1)) >= 1 && (await bn(n2)) >= 1) break; } catch {} await k.sleep(500); } + const b1 = await blockAt(n1, 1), b2 = await blockAt(n2, 1); + cycle.divergentAtHeight1 = !!(b1 && b2 && b1.hash !== b2.hash); + checks.check(cycle.divergentAtHeight1, `partitions divergent (node1 h1 ${b1?.hash?.slice(0, 10)} != node2 h1 ${b2?.hash?.slice(0, 10)})`); + + // Fund the displaced-tx sender on P1 (node1's miner pays the `miner` account), then execute T on node1 only. + const { parseEther } = await import('viem'); + await waitBalance(k.miner.address, parseEther('2'), n1); + const sNonce = await k.nonceOf(k.miner, n1); + const T = await k.signTx(k.miner, { nonce: sNonce, to: k.A.address, value: parseEther('1') }); + const Th = keccak256(T); + const sent = await k.send(n1, T); + const Trcpt = await k.waitReceipt(Th, 30_000, n1); + checks.check(Trcpt !== null, `displaced tx T executed on node1 before heal (block ${Trcpt && parseInt(Trcpt.blockNumber, 16)})`); + cycle.displacedTx = { hash: Th, executedOnNode1Block: Trcpt ? parseInt(Trcpt.blockNumber, 16) : null }; + + // Let the partitions diverge. + await k.sleep(SECS * 1000); + const h1 = await bn(n1), h2 = await bn(n2); + cycle.beforeHeal = { node1Height: h1, node2Height: h2 }; + + // Heal: connect the two partitions both ways. + k.injectCmd(['addpeer', '127.0.0.1:27621'], k.GRPC1); // node1 -> node2 + k.injectCmd(['addpeer', '127.0.0.1:27611'], 'grpc://127.0.0.1:27620'); // node2 -> node1 + + // Wait for convergence: all three nodes report the same sink. + let converged = false, sinks = null; + const tc = Date.now(); + while (Date.now() - tc < 60_000) { + const s1 = daginfoSink(k.GRPC1), s2 = daginfoSink('grpc://127.0.0.1:27620'), s3 = daginfoSink('grpc://127.0.0.1:27630'); + sinks = { s1, s2, s3 }; + if (s1 && s1 === s2 && s2 === s3) { converged = true; break; } + await k.sleep(1000); + } + checks.check(converged, `all three nodes converged to one sink after heal (${JSON.stringify(sinks)})`); + await k.sleep(2000); + + // Reorg depth observed on each node (the executor tracks the deepest selected-chain reorg). + const [g1, g2, g3] = [await budgets(n1), await budgets(n2), await budgets(n3)]; + const depths = [g1, g2, g3].map((g) => Number(BigInt(g.deepestReorg))); + cycle.deepestReorgPerNode = depths; + cycle.maxReorgDepth = Math.max(...depths); + checks.check(cycle.maxReorgDepth >= 1, `a reorg of depth >= 1 happened (per node ${depths.join(',')})`); + + // Determinism: all three nodes agree on the state root at a safe common height below the tips. + const common = Math.max(0, Math.min(await bn(n1), await bn(n2), await bn(n3)) - 3); + const roots = []; + for (const c of k.clients) { const b = await blockAt(c, common); roots.push(b?.stateRoot ?? null); } + const agree = roots[0] && roots.every((r) => r === roots[0]); + cycle.stateRootAtCommon = { height: common, roots }; + checks.check(agree, `state roots identical on all nodes at block ${common} after reorg (${roots[0]})`); + + // Displaced-tx handling on a BlockDAG. A selected-chain reorg does not orphan merged blocks (design 1.2/1.3: + // merged blocks, blue or red, are executed in the segment that merges them). So after the reorg T must resolve to + // ONE consistent outcome on every node: either executed exactly once at a block that is canonical on each node, + // or (if its funding did not survive) no receipt on any node. A receipt that disagrees across nodes, or points to + // a non-canonical block, is the failure. + const recs = []; + for (const c of k.clients) recs.push(await k.receiptOf(Th, c)); + const allNull = recs.every((r) => r === null); + const allSameBlock = recs.every((r) => r && recs[0] && r.blockHash === recs[0].blockHash && r.blockNumber === recs[0].blockNumber); + checks.check(allNull || allSameBlock, `displaced tx T resolves consistently on all nodes (${recs.map((r) => (r ? parseInt(r.blockNumber, 16) : 'null')).join(',')})`); + let canonical = true; + if (!allNull) { + for (let i = 0; i < k.clients.length; i++) { + const r = recs[i]; if (!r) { canonical = false; continue; } + const b = await blockAt(k.clients[i], parseInt(r.blockNumber, 16)); + if (!b || b.hash !== r.blockHash) canonical = false; + } + checks.check(canonical, `displaced tx T receipt points to a canonical block on every node (block ${recs[0] && parseInt(recs[0].blockNumber, 16)})`); + } + cycle.displacedAfterHeal = { resolvedConsistently: allNull || allSameBlock, executedBlock: recs[0] ? parseInt(recs[0].blockNumber, 16) : null, canonicalPointer: allNull ? 'n/a (not executed)' : canonical, originalBlock: cycle.displacedTx.executedOnNode1Block }; + + // Mempool not stuck: a fresh transaction from a P2-funded account (node2's miner pays B) lands after the reorg. + await waitBalance(k.B.address, parseEther('1'), n1, 40_000); + const uNonce = await k.nonceOf(k.B, n1); + const U = await k.signTx(k.B, { nonce: uNonce, to: k.C.address, value: 1n }); + const usent = await k.send(n1, U); + const Urcpt = await k.waitReceipt(keccak256(U), 40_000, n1); + checks.check(Urcpt !== null, `mempool not stuck: a new tx is mined after the reorg (${usent.error ?? 'ok'})`); + cycle.postReorgTxMined = Urcpt !== null; + + // Append this cycle to the aggregate result file. + const path = new URL('./results/scenario6.json', import.meta.url); + const agg = existsSync(path) ? JSON.parse(readFileSync(path)) : { scenario: '6-reorg-under-execution', cycles: [], checks: { pass: 0, fail: 0 } }; + cycle.summary = { pass: checks.pass, fail: checks.fail }; + agg.cycles.push(cycle); + agg.checks.pass += checks.pass; agg.checks.fail += checks.fail; + agg.maxReorgDepthSeen = Math.max(agg.maxReorgDepthSeen ?? 0, cycle.maxReorgDepth); + writeFileSync(path, JSON.stringify(agg, null, 2)); + checks.summary(`scenario 6 (partition ${SECS}s, reorg depth ${cycle.maxReorgDepth})`); + process.exit(checks.fail === 0 ? 0 : 1); +} +main().catch((e) => { console.error(e); process.exit(2); });