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