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.
571 lines
30 KiB
JavaScript
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`);
|
|
});
|
|
});
|