aere-contracts/test/rollup-settlement-invariant-property.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

571 lines
30 KiB
JavaScript

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