// ============================================================================= // SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the CANONICAL // per-rollup settlement template AereRollupSettlementV2 (the live RaaS template // deployed by AereRaaSFactoryV2 0xB7F8c754AC3155197d76f01857172bBd5a5F39Ab — // see sdk-js/src/addresses.ts). V1 (AereRollupSettlement) is deprecated; V2 is // the CANONICAL bug-fixed template, so the fuzzing targets V2. // // Source under test: contracts/raas/AereRollupSettlementV2.sol. // // TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry — `forge` // is not installed; the repo builds through hardhat with OZ 4.9.6 + viaIR). // Every random choice comes from mulberry32(seed); the base seed is printed at // suite start and pinnable via FUZZ_SEED. On any break the failing // (campaign, step, actor, action, values) is embedded in the assertion message // for a minimal repro. // // SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the // unrelated ML-DSA PQC KAT tests): // npx hardhat test test/rollup-settlement-invariant-property.test.js // No deploy to any live node, no contract-logic change, one new test file only. // // BOND MODEL (important, since the brief's invariants are written generically): // This contract has NO proposer/sequencer bond. The sequencer proposes state // roots bond-free. The ONLY bond is the CHALLENGER's bond, posted in // DEBT_TOKEN when it challenges a root. Its lifecycle is: // * resolveChallenge(accepted=true) → sequencer concedes root is fraudulent: // root REJECTED, challenger bond REFUNDED (challenge succeeded). // * resolveChallenge(accepted=false) → sequencer defends a valid root: // challenge DEFEATED, root survives, challenger bond SLASHED to SINK. // * resolveChallengeExpired() → sequencer never defended within the // window+grace: root REJECTED by default, challenger bond REFUNDED. // So "the bond of the losing party is slashed exactly once, to the correct // party" is asserted against the CHALLENGER bond, and "a successfully // challenged (fraudulent) root is rejected" against resolvedRejected. // // INVARIANTS FUZZED (the AereRollupSettlement brief, mapped to this contract): // * OPTIMISTIC SAFETY: a proposed root is never finalised before its challenge // window elapses. Concretely isFinalised(e) ⇒ (e ≤ latestFinalisedEpoch) and // every epoch in the finalised prefix has now ≥ proposedAt+WINDOW and is // neither challenged nor rejected. // * A successfully-challenged (conceded or expired) root is REJECTED // (resolvedRejected==true) and can never read as finalised. // * BOND CONSERVATION (no double-withdraw / no double-slash / no loss): // totalPosted == totalRefunded + totalSlashed + activeBondEscrow at all times. // * ESCROW ACCOUNTING: activeBondEscrow == Σ challengeBond over currently // challenged epochs, and the contract's DEBT_TOKEN balance == activeBondEscrow // (every escrowed bond is fully backed; nothing extra sticks with a healthy // sink). // * CORRECT RECIPIENT: a refunded bond goes to the challenger and 0 to the sink; // a slashed bond goes to the sink and 0 to the challenger; the sequencer never // receives a bond. // * NO DOUBLE RESOLUTION: an already-resolved challenge cannot be resolved again // (revert), so no bond is paid twice. // * FINALITY MONOTONICITY: latestFinalisedEpoch never decreases; a finalised // epoch is immutable (never re-challengeable, never replaceable). // * advanceFinalisation is EXACT: right after it runs, the first non-final epoch // (if any) is NOT settled — it never skips a settleable epoch nor finalises a // non-settled one. // * F6 grace + F7 healing behave (targeted deterministic tests). // // No em-dashes anywhere in this file. // ============================================================================= const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const { time } = require("@nomicfoundation/hardhat-network-helpers"); /* ------------------------------- seeded PRNG ------------------------------ */ function mulberry32(a) { return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0x2011a5e2; function rngFor(tag, campaign) { let h = BASE_SEED ^ (campaign * 0x9e3779b1); for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0; return mulberry32(h >>> 0); } const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)]; const chance = (rng, p) => rng() < p; /* ------------------------------- misc helpers ----------------------------- */ const E = (n) => ethers.parseEther(String(n)); const MAXU = ethers.MaxUint256; const WINDOW = 6 * 60 * 60; // 21600 s challenge window const GRACE = 60 * 60; // 3600 s defense grace (F6) const MIN_BOND = E(10); // MIN_CHALLENGE_BOND const SINK_BPS = 1000n, ROLLUP_BPS = 8000n, SEQ_BPS = 1000n; // sum 10000 // struct StateRoot decode indices from the public roots(epoch) getter: // [0] root [1] l2BlockHash [2] proposedAt [3] challenged [4] resolvedRejected // [5] challenger [6] challengeBond const R = { PROPOSED_AT: 2, CHALLENGED: 3, REJECTED: 4, CHALLENGER: 5, BOND: 6 }; async function latestTs() { return Number((await ethers.provider.getBlock("latest")).timestamp); } function rndRoot(rng) { // 32-byte pseudo-random root let hex = "0x"; for (let i = 0; i < 64; i++) hex += "0123456789abcdef"[Math.floor(rng() * 16)]; return hex; } function rndBond(rng) { // MIN_BOND .. MIN_BOND + up to 5000 AERE, plus occasional exact-min if (chance(rng, 0.25)) return MIN_BOND; return MIN_BOND + E(ri(rng, 0, 5000)) + BigInt(ri(rng, 0, 1_000_000)); } // Iteration counts (env-overridable so a quick smoke can shrink them). const N = { CAMPAIGNS: Number(process.env.RS_CAMPAIGNS || 6), STEPS: Number(process.env.RS_STEPS || 80), }; // ============================================================================= describe("INVARIANT: AereRollupSettlementV2 optimistic rollup roots + challenge bonds", function () { this.timeout(0); let signers, tokenF, sinkF, rsF; before(async function () { signers = await ethers.getSigners(); tokenF = await ethers.getContractFactory("MockERC20Lending"); sinkF = await ethers.getContractFactory("MockSinkSimple"); rsF = await ethers.getContractFactory("AereRollupSettlementV2"); console.log( ` [rs] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} randomized multi-actor sequences` ); }); // Roles are kept disjoint so bond accounting is never polluted by revenue: // deployer = signers[0] // rollupOwner (settles revenue, receives rollupBps) = ownerA/ownerB // sequencer (proposes/resolves, receives sequencerBps) = seqA/seqB // challengers (post bonds, receive refunds) = signers[6..9] // Challengers NEVER receive revenue, so their DEBT_TOKEN wallet delta at a // resolution equals exactly the refunded bond. The sink only receives revenue // (measured on settleRevenue steps) and slashed bonds (measured on defend // steps); since one action runs per step the two never overlap in a step. async function deployStack() { const deployer = signers[0]; const ownerA = signers[1], ownerB = signers[2]; const seqA = signers[3], seqB = signers[4]; const challengers = [signers[6], signers[7], signers[8], signers[9]]; const token = await tokenF.connect(deployer).deploy("DebtToken", "DEBT", 18); await token.waitForDeployment(); const sink = await sinkF.connect(deployer).deploy(); await sink.waitForDeployment(); const cfg = { rollupId: ethers.zeroPadValue("0x01", 32), rollupOwner: ownerA.address, sequencer: seqA.address, sink: await sink.getAddress(), debtToken: await token.getAddress(), challengeWindow: WINDOW, defenseGrace: GRACE, sinkBps: Number(SINK_BPS), rollupBps: Number(ROLLUP_BPS), sequencerBps: Number(SEQ_BPS), minChallengeBond: MIN_BOND, }; const rs = await rsF.connect(deployer).deploy(cfg); await rs.waitForDeployment(); const rsAddr = await rs.getAddress(); // Fund + approve. Owners fund for settleRevenue; challengers for bonds. for (const a of [ownerA, ownerB, ...challengers]) { await (await token.mint(a.address, E(10_000_000))).wait(); await (await token.connect(a).approve(rsAddr, MAXU)).wait(); } return { token, sink, rs, rsAddr, ownerA, ownerB, seqA, seqB, challengers }; } // Scan on-chain epoch structs 1..latestEpoch into a lightweight JS view. async function scanEpochs(rs) { const latest = Number(await rs.latestEpoch()); const out = []; for (let e = 1; e <= latest; e++) { const r = await rs.roots(e); out.push({ epoch: e, proposedAt: Number(r[R.PROPOSED_AT]), challenged: r[R.CHALLENGED], rejected: r[R.REJECTED], challenger: r[R.CHALLENGER], bond: r[R.BOND], }); } return out; } // The core invariant battery, run after EVERY step. `state` carries cumulative // bond accounting and the monotonic finality watermark. async function assertInvariants(ctx, rs, token, rsAddr, sinkAddr, seqAddr, state) { const now = await latestTs(); const latestEpoch = Number(await rs.latestEpoch()); const prefix = Number(await rs.latestFinalisedEpoch()); const escrow = await rs.activeBondEscrow(); const bal = await token.balanceOf(rsAddr); const epochs = await scanEpochs(rs); // ---- FINALITY MONOTONICITY: watermark never regresses ---- expect(prefix, `[rs FINAL-MONO] latestFinalisedEpoch regressed ${ctx} prev=${state.prevPrefix} now=${prefix}`) .to.be.gte(state.prevPrefix); state.prevPrefix = prefix; expect(prefix, `[rs FINAL-BOUND] finalised prefix past latestEpoch ${ctx}`).to.be.lte(latestEpoch); // ---- ESCROW == Σ challenged bonds ---- let sumChallenged = 0n; for (const e of epochs) if (e.challenged) sumChallenged += e.bond; expect(escrow, `[rs ESCROW-SUM] activeBondEscrow != Σ challenged bonds ${ctx} escrow=${escrow} sum=${sumChallenged}`) .to.equal(sumChallenged); // ---- Every escrowed bond is fully backed; nothing extra sticks (healthy // sink pushes all revenue+slashes out, so balance is EXACTLY escrow) ---- expect(bal, `[rs BOND-BACKED] token balance < escrow ${ctx} bal=${bal} escrow=${escrow}`).to.be.gte(escrow); expect(bal, `[rs BALANCE-EXACT] token balance != escrow ${ctx} bal=${bal} escrow=${escrow}`).to.equal(escrow); // ---- BOND CONSERVATION (no double-withdraw / no double-slash / no loss) ---- expect( state.posted, `[rs BOND-CONSERVE] posted != refunded+slashed+escrow ${ctx} ` + `posted=${state.posted} refunded=${state.refunded} slashed=${state.slashed} escrow=${escrow}` ).to.equal(state.refunded + state.slashed + escrow); // ---- OPTIMISTIC SAFETY over the whole finalised prefix ---- for (const e of epochs) { const finalised = await rs.isFinalised(e.epoch); // isFinalised is exactly membership of the contiguous prefix expect(finalised, `[rs FINAL-DEF] isFinalised != (epoch<=prefix) ${ctx} e=${e.epoch} prefix=${prefix}`) .to.equal(e.epoch <= prefix); if (e.epoch <= prefix) { // A finalised epoch: window MUST have elapsed, and it is neither // challenged nor rejected. This is the no-premature-finality guarantee. expect(now, `[rs SAFETY-WINDOW] finalised before window elapsed ${ctx} e=${e.epoch} now=${now} pa=${e.proposedAt}`) .to.be.gte(e.proposedAt + WINDOW); expect(e.challenged, `[rs SAFETY-CHAL] challenged epoch is finalised ${ctx} e=${e.epoch}`).to.equal(false); expect(e.rejected, `[rs SAFETY-REJ] rejected epoch is finalised ${ctx} e=${e.epoch}`).to.equal(false); } // A challenged or rejected epoch can NEVER be finalised. if (e.challenged || e.rejected) { expect(finalised, `[rs CHAL-REJ-NOTFINAL] challenged/rejected epoch reads final ${ctx} e=${e.epoch}`) .to.equal(false); expect(e.epoch, `[rs CHAL-REJ-PREFIX] challenged/rejected epoch inside prefix ${ctx} e=${e.epoch} prefix=${prefix}`) .to.be.gt(prefix); } } } // Read a single epoch struct fresh. async function readEpoch(rs, e) { const r = await rs.roots(e); return { proposedAt: Number(r[R.PROPOSED_AT]), challenged: r[R.CHALLENGED], rejected: r[R.REJECTED], challenger: r[R.CHALLENGER], bond: r[R.BOND], }; } // ------------------------------------------------------------------------- it("randomized interleaving: no premature finality, bond conservation, correct-recipient slashing", async function () { let steps = 0, proposes = 0, reproposes = 0, challenges = 0, accepts = 0, defends = 0, expireds = 0, advances = 0, revenues = 0, rotations = 0, doubleResolveReverts = 0; for (let c = 0; c < N.CAMPAIGNS; c++) { const rng = rngFor("gen", c); const stk = await deployStack(); const { token, sink, rs, rsAddr, challengers } = stk; const sinkAddr = await sink.getAddress(); let curOwner = stk.ownerA, curSeq = stk.seqA; const state = { posted: 0n, refunded: 0n, slashed: 0n, prevPrefix: 0 }; // resolvedEpochs: epochs whose challenge has been resolved (for the // no-double-resolution probe). Cleared for an epoch on re-propose. const resolvedEpochs = new Set(); await assertInvariants(`c${c} init`, rs, token, rsAddr, sinkAddr, curSeq.address, state); for (let s = 0; s < N.STEPS; s++) { const action = pick(rng, [ "propose", "propose", "propose", "challenge", "challenge", "accept", "defend", "defend", "expired", "advance", "advance", "revenue", "repropose", "rotate", "doubleResolve", ]); const ctx = `c${c} s${s} action=${action}`; const now = await latestTs(); const latestEpoch = Number(await rs.latestEpoch()); try { if (action === "propose") { const e = latestEpoch + 1; await (await rs.connect(curSeq).proposeStateRoot(e, rndRoot(rng), rndRoot(rng))).wait(); resolvedEpochs.delete(e); proposes++; } else if (action === "repropose") { // heal a rejected epoch above the finalised prefix (F7) const prefix = Number(await rs.latestFinalisedEpoch()); const epochs = await scanEpochs(rs); const cands = epochs.filter((x) => x.rejected && x.epoch > prefix); if (cands.length === 0) { steps++; continue; } const t = pick(rng, cands).epoch; await (await rs.connect(curSeq).proposeStateRoot(t, rndRoot(rng), rndRoot(rng))).wait(); resolvedEpochs.delete(t); reproposes++; } else if (action === "challenge") { // challengeable: proposed, not rejected, no prior challenger, in window const epochs = await scanEpochs(rs); const cands = epochs.filter( (x) => x.proposedAt !== 0 && !x.rejected && x.challenger === ethers.ZeroAddress && now < x.proposedAt + WINDOW ); if (cands.length === 0) { steps++; continue; } const t = pick(rng, cands).epoch; const chGr = pick(rng, challengers); const bond = rndBond(rng); const before = await token.balanceOf(rsAddr); await (await rs.connect(chGr).challenge(t, bond, rndRoot(rng))).wait(); const got = (await token.balanceOf(rsAddr)) - before; expect(got, `[rs CHALLENGE-PULL] escrow pull != bond ${ctx} e=${t}`).to.equal(bond); state.posted += bond; challenges++; } else if (action === "accept" || action === "defend") { // sequencer resolves a challenged epoch still inside window+grace const epochs = await scanEpochs(rs); const cands = epochs.filter((x) => x.challenged && now < x.proposedAt + WINDOW + GRACE); if (cands.length === 0) { steps++; continue; } const t = pick(rng, cands).epoch; const pre = await readEpoch(rs, t); const chBalBefore = await token.balanceOf(pre.challenger); const sinkBalBefore = await token.balanceOf(sinkAddr); const accepted = action === "accept"; await (await rs.connect(curSeq).resolveChallenge(t, accepted)).wait(); const chDelta = (await token.balanceOf(pre.challenger)) - chBalBefore; const sinkDelta = (await token.balanceOf(sinkAddr)) - sinkBalBefore; const post = await readEpoch(rs, t); expect(post.challenged, `[rs RESOLVE-FLAG] challenged not cleared ${ctx} e=${t}`).to.equal(false); expect(post.bond, `[rs RESOLVE-BOND] challengeBond not zeroed ${ctx} e=${t}`).to.equal(0n); if (accepted) { // conceded fraudulent root: rejected + challenger refunded, sink 0 expect(post.rejected, `[rs ACCEPT-REJECT] accepted root not rejected ${ctx} e=${t}`).to.equal(true); expect(chDelta, `[rs ACCEPT-REFUND] challenger refund != bond ${ctx} e=${t}`).to.equal(pre.bond); expect(sinkDelta, `[rs ACCEPT-SINK0] sink received on accept ${ctx} e=${t}`).to.equal(0n); state.refunded += pre.bond; accepts++; } else { // defended valid root: NOT rejected, challenger bond SLASHED to sink expect(post.rejected, `[rs DEFEND-NOREJECT] defended root wrongly rejected ${ctx} e=${t}`).to.equal(false); expect(sinkDelta, `[rs DEFEND-SLASH] sink slash != bond ${ctx} e=${t}`).to.equal(pre.bond); expect(chDelta, `[rs DEFEND-CH0] challenger received on defend ${ctx} e=${t}`).to.equal(0n); state.slashed += pre.bond; defends++; } resolvedEpochs.add(t); } else if (action === "expired") { // anyone resolves an expired (past window+grace) still-challenged epoch const epochs = await scanEpochs(rs); const cands = epochs.filter((x) => x.challenged && now >= x.proposedAt + WINDOW + GRACE); if (cands.length === 0) { steps++; continue; } const t = pick(rng, cands).epoch; const pre = await readEpoch(rs, t); const caller = pick(rng, challengers); // permissionless const chBalBefore = await token.balanceOf(pre.challenger); const sinkBalBefore = await token.balanceOf(sinkAddr); await (await rs.connect(caller).resolveChallengeExpired(t)).wait(); const chDelta = (await token.balanceOf(pre.challenger)) - chBalBefore; const sinkDelta = (await token.balanceOf(sinkAddr)) - sinkBalBefore; const post = await readEpoch(rs, t); expect(post.rejected, `[rs EXPIRED-REJECT] expired root not rejected ${ctx} e=${t}`).to.equal(true); expect(post.challenged, `[rs EXPIRED-FLAG] challenged not cleared ${ctx} e=${t}`).to.equal(false); expect(chDelta, `[rs EXPIRED-REFUND] challenger refund != bond ${ctx} e=${t}`).to.equal(pre.bond); expect(sinkDelta, `[rs EXPIRED-SINK0] sink received on expired ${ctx} e=${t}`).to.equal(0n); state.refunded += pre.bond; resolvedEpochs.add(t); expireds++; } else if (action === "advance") { await time.increase(ri(rng, 60, Math.floor(WINDOW * 1.5))); await network.provider.send("evm_mine"); // opportunistically extend the finalised prefix and check exactness await (await rs.advanceFinalisation()).wait(); const prefix = Number(await rs.latestFinalisedEpoch()); const le = Number(await rs.latestEpoch()); if (prefix < le) { // the first non-final epoch must NOT be settleable, else advance // would have consumed it (advanceFinalisation exactness). const settled = await rs.isEpochSettled(prefix + 1); expect(settled, `[rs ADVANCE-EXACT] settleable epoch left unfinalised ${ctx} e=${prefix + 1}`) .to.equal(false); } advances++; } else if (action === "revenue") { const amt = E(ri(rng, 1, 100000)) + BigInt(ri(rng, 0, 1_000_000)); // net token flow through the contract is zero (all split out); the // balance-exact invariant afterward proves nothing stuck. await (await rs.connect(curOwner).settleRevenue(amt)).wait(); revenues++; } else if (action === "rotate") { if (chance(rng, 0.5)) { const ns = curSeq.address === stk.seqA.address ? stk.seqB : stk.seqA; await (await rs.connect(curOwner).setSequencer(ns.address)).wait(); curSeq = ns; } else { const no = curOwner.address === stk.ownerA.address ? stk.ownerB : stk.ownerA; await (await rs.connect(curOwner).transferOwner(no.address)).wait(); curOwner = no; } rotations++; } else if (action === "doubleResolve") { // NO DOUBLE RESOLUTION: an already-resolved challenge must revert on // any further resolve, moving no funds. if (resolvedEpochs.size === 0) { steps++; continue; } const arr = [...resolvedEpochs]; const t = arr[Math.floor(rng() * arr.length)]; const cur = await readEpoch(rs, t); if (cur.challenged) { steps++; continue; } // was re-challenged since; skip const balBefore = await token.balanceOf(rsAddr); let reverted = false; try { await (await rs.connect(curSeq).resolveChallenge(t, chance(rng, 0.5))).wait(); } catch (_) { reverted = true; } if (!reverted) { try { await (await rs.connect(pick(rng, challengers)).resolveChallengeExpired(t)).wait(); } catch (_) { reverted = true; } } expect(reverted, `[rs NO-DOUBLE-RESOLVE] resolved challenge resolved again ${ctx} e=${t}`).to.equal(true); expect(await token.balanceOf(rsAddr), `[rs NO-DOUBLE-BAL] balance moved on double-resolve ${ctx} e=${t}`) .to.equal(balBefore); doubleResolveReverts++; } } catch (e) { // Legal reverts on edge timing/ordering are fine; a broken INVARIANT // assertion re-throws (it carries the [rs ...] tag). if (/\[rs [A-Z]/.test(String(e.message))) throw e; } await assertInvariants(ctx, rs, token, rsAddr, sinkAddr, curSeq.address, state); steps++; } // ---- finale: push time well past every window+grace, resolve whatever is // still challenged (expired path), then fully advance finalisation. ---- await time.increase(WINDOW + GRACE + 10); await network.provider.send("evm_mine"); for (const e of await scanEpochs(rs)) { if (e.challenged) { const pre = await readEpoch(rs, e.epoch); const chBefore = await token.balanceOf(pre.challenger); await (await rs.connect(challengers[0]).resolveChallengeExpired(e.epoch)).wait(); const d = (await token.balanceOf(pre.challenger)) - chBefore; expect(d, `[rs FINALE-REFUND] c${c} e=${e.epoch}`).to.equal(pre.bond); state.refunded += pre.bond; } } await (await rs.advanceFinalisation()).wait(); await assertInvariants(`c${c} finale`, rs, token, rsAddr, sinkAddr, curSeq.address, state); // all bonds settled: nothing escrowed, everything either refunded or slashed expect(await rs.activeBondEscrow(), `[rs FINALE-ESCROW0] c${c}`).to.equal(0n); expect(state.posted, `[rs FINALE-CONSERVE] c${c}`).to.equal(state.refunded + state.slashed); expect(await token.balanceOf(rsAddr), `[rs FINALE-DRAINED] c${c}`).to.equal(0n); } console.log( ` [rs gen] steps=${steps} propose=${proposes} repropose=${reproposes} challenge=${challenges} ` + `accept=${accepts} defend=${defends} expired=${expireds} advance=${advances} revenue=${revenues} ` + `rotate=${rotations} doubleResolveReverts=${doubleResolveReverts}` ); expect(steps).to.be.greaterThan(400); expect(proposes).to.be.greaterThan(40); expect(challenges).to.be.greaterThan(20); expect(accepts + defends + expireds).to.be.greaterThan(20); expect(defends, "no defended (slash-to-sink) resolutions exercised").to.be.greaterThan(0); }); // ------------------------------------------------------------------------- // Targeted F6 property: a challenge in the LAST in-window block can STILL be // defended during the grace period, and defending SLASHES the challenger to the // sink (grief is costly). Before the grace expires, resolveChallengeExpired // reverts WindowOpen (no premature default-reject / refund). it("F6 grace: last-block challenge is defensible in grace, slashed to sink, expired-path gated until grace ends", async function () { const { token, sink, rs, rsAddr, ownerA, seqA, challengers } = await deployStack(); const sinkAddr = await sink.getAddress(); const ch = challengers[0]; await (await rs.connect(seqA).proposeStateRoot(1, rndRoot(mulberry32(1)), rndRoot(mulberry32(2)))).wait(); const pa = (await readEpoch(rs, 1)).proposedAt; // jump to the last in-window instant, then challenge await time.increaseTo(pa + WINDOW - 2); const bond = MIN_BOND + E(123); await (await rs.connect(ch).challenge(1, bond, rndRoot(mulberry32(3)))).wait(); expect((await readEpoch(rs, 1)).challenged, "[rs F6] challenge did not land").to.equal(true); expect(await rs.activeBondEscrow(), "[rs F6] escrow != bond after challenge").to.equal(bond); // now past window (challenge window closed) but within grace: expired path // MUST be gated, sequencer defense MUST still work. await time.increaseTo(pa + WINDOW + 5); let expiredReverted = false; try { await (await rs.connect(ch).resolveChallengeExpired(1)).wait(); } catch (_) { expiredReverted = true; } expect(expiredReverted, "[rs F6] resolveChallengeExpired not gated during grace").to.equal(true); // sequencer defends the valid root -> challenger bond slashed to sink const sinkBefore = await token.balanceOf(sinkAddr); const chBefore = await token.balanceOf(ch.address); await (await rs.connect(seqA).resolveChallenge(1, false)).wait(); expect((await token.balanceOf(sinkAddr)) - sinkBefore, "[rs F6] slash to sink != bond").to.equal(bond); expect((await token.balanceOf(ch.address)) - chBefore, "[rs F6] challenger refunded on defend").to.equal(0n); const post = await readEpoch(rs, 1); expect(post.rejected, "[rs F6] defended root wrongly rejected").to.equal(false); expect(await rs.activeBondEscrow(), "[rs F6] escrow not cleared after slash").to.equal(0n); // valid root now settles after the window and finalises await (await rs.advanceFinalisation()).wait(); expect(await rs.isFinalised(1), "[rs F6] defended valid root did not finalise").to.equal(true); console.log(` [rs F6] last-block challenge slashed ${ethers.formatEther(bond)} DEBT to sink, valid root finalised`); }); // ------------------------------------------------------------------------- // Targeted F7 property: one griefing challenge that rejects an epoch does NOT // permanently freeze finalisation. The sequencer re-proposes the rejected epoch; // after its fresh window it settles and the contiguous prefix heals past it. it("F7 healing: a rejected epoch can be re-proposed and finalisation resumes across the gap", async function () { const { rs, seqA, challengers } = await deployStack(); const rng = mulberry32(0xF7); const ch = challengers[0]; // propose epochs 1,2,3 for (let e = 1; e <= 3; e++) { await (await rs.connect(seqA).proposeStateRoot(e, rndRoot(rng), rndRoot(rng))).wait(); } // challenge epoch 2, let it expire -> rejected (grief), freezing the prefix at 1 const pa2 = (await readEpoch(rs, 2)).proposedAt; await (await rs.connect(ch).challenge(2, MIN_BOND, rndRoot(rng))).wait(); await time.increaseTo(pa2 + WINDOW + GRACE + 1); await (await rs.connect(ch).resolveChallengeExpired(2)).wait(); expect((await readEpoch(rs, 2)).rejected, "[rs F7] epoch2 not rejected").to.equal(true); // epochs 1 and 3 are past their windows now, but the prefix is STUCK at 1 // because epoch 2 is a rejected gap. await (await rs.advanceFinalisation()).wait(); expect(await rs.latestFinalisedEpoch(), "[rs F7] prefix should be frozen at 1").to.equal(1n); expect(await rs.isFinalised(3), "[rs F7] epoch3 must not be final over a rejected gap").to.equal(false); // heal: re-propose epoch 2, wait out its fresh window, advance await (await rs.connect(seqA).proposeStateRoot(2, rndRoot(rng), rndRoot(rng))).wait(); expect((await readEpoch(rs, 2)).rejected, "[rs F7] re-propose did not clear rejected").to.equal(false); await time.increase(WINDOW + 5); await network.provider.send("evm_mine"); await (await rs.advanceFinalisation()).wait(); expect(await rs.latestFinalisedEpoch(), "[rs F7] finalisation did not heal past the gap").to.equal(3n); expect(await rs.isFinalised(3), "[rs F7] epoch3 not final after heal").to.equal(true); console.log(` [rs F7] rejected epoch2 re-proposed, prefix healed 1 -> 3`); }); });