// AUDIT FIX VERIFICATION — AereRollupSettlementV2 + AereRaaSFactoryV2 + AereCompliancePoolV2 — 2026-07-10 // // For each confirmed finding: V1 REPRODUCES the bug, V2 BLOCKS/CORRECTS it. // Runs fully local against the REAL contract sources (same source deployed to chain 2800). // // F6 last-block challenge grief — V2 grace lets the sequencer defend; griefer bond slashed to sink // F7 rejected epoch freezes final — V2 re-proposes a rejected epoch; finalisation resumes // ANC isFinalised ancestor unsafe — V2 isFinalised is ancestor-aware (descendant of a gap != final) // F8 compliance challenge bond — V2 refunds an UPHELD challenger; only a DISMISSED bond is burned const { expect } = require("chai"); const { ethers } = require("hardhat"); const ONE = 10n ** 18n; const DEAD = "0x000000000000000000000000000000000000dEaD"; async function setNextTs(t) { await ethers.provider.send("evm_setNextBlockTimestamp", [Number(t)]); } async function mine() { await ethers.provider.send("evm_mine", []); } async function nowTs() { return BigInt((await ethers.provider.getBlock("latest")).timestamp); } // ═══════════════════════════════ ROLLUP ═══════════════════════════════ describe("AereRollupSettlementV2 / AereRaaSFactoryV2 — audit fixes (V1 reproduces, V2 corrects)", function () { this.timeout(300000); const WINDOW = 3600n; const GRACE = 1800n; async function tokenAndSigners() { const [deployer, owner, sequencer, sinkEOA, challenger] = await ethers.getSigners(); const T = await ethers.getContractFactory("MockERC20Lending"); const dbt = await T.deploy("WAERE", "WAERE", 18); await dbt.waitForDeployment(); await dbt.mint(challenger.address, 1000n * ONE); return { deployer, owner, sequencer, sinkEOA, challenger, dbt }; } async function deployV1() { const { owner, sequencer, sinkEOA, challenger, dbt } = await tokenAndSigners(); const RS = await ethers.getContractFactory("AereRollupSettlement"); const cfg = [ ethers.id("rollup:v1"), owner.address, sequencer.address, sinkEOA.address, await dbt.getAddress(), WINDOW, 1000, 8000, 1000, ONE, ]; const rs = await RS.deploy(cfg); await rs.waitForDeployment(); await dbt.connect(challenger).approve(await rs.getAddress(), ethers.MaxUint256); return { rs, dbt, sequencer, challenger }; } async function deployV2() { const { owner, sequencer, challenger, dbt } = await tokenAndSigners(); const Sink = await ethers.getContractFactory("MockSinkSimple"); const sink = await Sink.deploy(); await sink.waitForDeployment(); const RS = await ethers.getContractFactory("AereRollupSettlementV2"); const cfg = [ ethers.id("rollup:v2"), owner.address, sequencer.address, await sink.getAddress(), await dbt.getAddress(), WINDOW, GRACE, 1000, 8000, 1000, ONE, ]; const rs = await RS.deploy(cfg); await rs.waitForDeployment(); await dbt.connect(challenger).approve(await rs.getAddress(), ethers.MaxUint256); return { rs, dbt, sink, owner, sequencer, challenger }; } // ───────────────────────── F6 ───────────────────────── describe("F6 last-block challenge grief", function () { it("V1 REPRODUCES: last-block challenge cannot be defended; valid root rejected, bond fully refunded, no slash", async () => { const { rs, dbt, sequencer, challenger } = await deployV1(); await rs.connect(sequencer).proposeStateRoot(1, ethers.id("root1"), ethers.id("l2#1")); const proposedAt = (await rs.roots(1)).proposedAt; const bondBefore = await dbt.balanceOf(challenger.address); await setNextTs(proposedAt + WINDOW - 1n); await rs.connect(challenger).challenge(1, ONE, ethers.id("reason")); await setNextTs(proposedAt + WINDOW); await expect(rs.connect(sequencer).resolveChallenge(1, false)).to.be.revertedWithCustomError(rs, "WindowClosed"); await rs.resolveChallengeExpired(1); expect((await rs.roots(1)).resolvedRejected).to.equal(true); // valid root killed expect(await rs.isFinalised(1)).to.equal(false); expect(await dbt.balanceOf(challenger.address)).to.equal(bondBefore); // full refund, no slash }); it("V2 CORRECTS: sequencer defends a last-block challenge in the grace; root survives + finalises; griefer bond SLASHED to sink", async () => { const { rs, dbt, sink, sequencer, challenger } = await deployV2(); await rs.connect(sequencer).proposeStateRoot(1, ethers.id("root1"), ethers.id("l2#1")); const proposedAt = (await rs.roots(1)).proposedAt; const bondBefore = await dbt.balanceOf(challenger.address); // challenge in the LAST in-window block await setNextTs(proposedAt + WINDOW - 1n); await rs.connect(challenger).challenge(1, ONE, ethers.id("reason")); expect(await rs.activeBondEscrow()).to.equal(ONE); // window is CLOSED, but the grace is still open -> sequencer CAN defend await setNextTs(proposedAt + WINDOW + 10n); await rs.connect(sequencer).resolveChallenge(1, false); const s = await rs.roots(1); expect(s.resolvedRejected).to.equal(false); // valid root SURVIVES expect(s.challenged).to.equal(false); expect(await rs.activeBondEscrow()).to.equal(0n); // griefer's bond slashed to sink (not refunded) expect(await dbt.balanceOf(challenger.address)).to.equal(bondBefore - ONE); expect(await dbt.balanceOf(await sink.getAddress())).to.equal(ONE); expect(await sink.lastFlushAmount()).to.equal(ONE); // settled + finalises expect(await rs.isEpochSettled(1)).to.equal(true); await rs.advanceFinalisation(); expect(await rs.latestFinalisedEpoch()).to.equal(1n); expect(await rs.isFinalised(1)).to.equal(true); }); }); // ───────────────────────── F7 ───────────────────────── describe("F7 rejected epoch freezes finalisation", function () { it("V1 REPRODUCES: one rejected epoch permanently freezes latestFinalisedEpoch and cannot be re-proposed", async () => { const { rs, sequencer, challenger } = await deployV1(); await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1"), ethers.id("h1")); const p1 = (await rs.roots(1)).proposedAt; await setNextTs(p1 + WINDOW - 1n); await rs.connect(challenger).challenge(1, ONE, ethers.id("x")); await setNextTs(p1 + WINDOW); await rs.resolveChallengeExpired(1); expect((await rs.roots(1)).resolvedRejected).to.equal(true); await rs.connect(sequencer).proposeStateRoot(2, ethers.id("r2"), ethers.id("h2")); const p2 = (await rs.roots(2)).proposedAt; await setNextTs(p2 + WINDOW + 1n); await mine(); expect(await rs.isFinalised(2)).to.equal(true); // V1 local notion (unsafe) await rs.advanceFinalisation(); expect(await rs.latestFinalisedEpoch()).to.equal(0n); // frozen // epoch 1 can NEVER be re-proposed (strict continuity needs latestEpoch+1 = 3) await expect( rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1b"), ethers.id("h1b")) ).to.be.revertedWithCustomError(rs, "EpochOutOfOrder"); }); it("V2 CORRECTS: a rejected epoch is re-proposable; finalisation resumes; one griefing challenge cannot freeze it", async () => { const { rs, sequencer, challenger } = await deployV2(); // epoch 1 rejected via the sequencer-absent expired path (worst case) await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1"), ethers.id("h1")); const p1 = (await rs.roots(1)).proposedAt; await setNextTs(p1 + 10n); await rs.connect(challenger).challenge(1, ONE, ethers.id("x")); await setNextTs(p1 + WINDOW + GRACE); await rs.resolveChallengeExpired(1); expect((await rs.roots(1)).resolvedRejected).to.equal(true); await rs.advanceFinalisation(); expect(await rs.latestFinalisedEpoch()).to.equal(0n); // epoch 2 proposed + individually settled await rs.connect(sequencer).proposeStateRoot(2, ethers.id("r2"), ethers.id("h2")); const p2 = (await rs.roots(2)).proposedAt; await setNextTs(p2 + WINDOW + 1n); await mine(); expect(await rs.isEpochSettled(2)).to.equal(true); // ancestor-aware: epoch 2 is NOT final while epoch 1 is a gap await rs.advanceFinalisation(); expect(await rs.latestFinalisedEpoch()).to.equal(0n); expect(await rs.isFinalised(2)).to.equal(false); // sequencer heals the gap: re-propose epoch 1 (replacement) await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1b"), ethers.id("h1b")); expect((await rs.roots(1)).resolvedRejected).to.equal(false); const p1b = (await rs.roots(1)).proposedAt; await setNextTs(p1b + WINDOW + 1n); await mine(); // now the whole prefix finalises await rs.advanceFinalisation(); expect(await rs.latestFinalisedEpoch()).to.equal(2n); expect(await rs.isFinalised(1)).to.equal(true); expect(await rs.isFinalised(2)).to.equal(true); }); }); // ───────────────────────── ANCESTOR ───────────────────────── describe("isFinalised ancestor safety", function () { it("V1 REPRODUCES: a descendant of a rejected epoch reads isFinalised == true (unsafe)", async () => { const { rs, sequencer, challenger } = await deployV1(); await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1"), ethers.id("h1")); const p1 = (await rs.roots(1)).proposedAt; await setNextTs(p1 + WINDOW - 1n); await rs.connect(challenger).challenge(1, ONE, ethers.id("x")); await setNextTs(p1 + WINDOW); await rs.resolveChallengeExpired(1); await rs.connect(sequencer).proposeStateRoot(2, ethers.id("r2"), ethers.id("h2")); const p2 = (await rs.roots(2)).proposedAt; await setNextTs(p2 + WINDOW + 1n); await mine(); expect(await rs.isFinalised(1)).to.equal(false); expect(await rs.isFinalised(2)).to.equal(true); // descendant of a rejected ancestor reads final }); it("V2 CORRECTS: a descendant of a rejected epoch reads isFinalised == false until the gap heals", async () => { const { rs, sequencer, challenger } = await deployV2(); await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1"), ethers.id("h1")); const p1 = (await rs.roots(1)).proposedAt; await setNextTs(p1 + 10n); await rs.connect(challenger).challenge(1, ONE, ethers.id("x")); await setNextTs(p1 + WINDOW + GRACE); await rs.resolveChallengeExpired(1); await rs.connect(sequencer).proposeStateRoot(2, ethers.id("r2"), ethers.id("h2")); const p2 = (await rs.roots(2)).proposedAt; await setNextTs(p2 + WINDOW + 1n); await mine(); await rs.advanceFinalisation(); expect(await rs.isEpochSettled(2)).to.equal(true); // locally settled expect(await rs.isFinalised(2)).to.equal(false); // but NOT final (ancestor gap) }); }); // ───────────────────────── FACTORY ───────────────────────── describe("AereRaaSFactoryV2 deploys the fixed template", function () { async function deployFactory() { const [deployer] = await ethers.getSigners(); const T = await ethers.getContractFactory("MockERC20Lending"); const bond = await T.deploy("WAERE", "WAERE", 18); await bond.waitForDeployment(); const Sink = await ethers.getContractFactory("MockSinkSimple"); const sink = await Sink.deploy(); await sink.waitForDeployment(); const F = await ethers.getContractFactory("AereRaaSFactoryV2"); // bondToken, sink, foundation, registrationBond=0, minSinkBps=1000, defWindow, defGrace const f = await F.deploy(await bond.getAddress(), await sink.getAddress(), deployer.address, 0, 1000, WINDOW, GRACE); await f.waitForDeployment(); return { f, bond, sink, deployer }; } it("registers a rollup and the settlement is a V2 with DEFENSE_GRACE defaulted", async () => { const { f, bond, sink, deployer } = await deployFactory(); const rid = ethers.id("rollup:factory-demo"); // defenseGrace = 0 -> falls back to DEFAULT_DEFENSE_GRACE await f.registerRollup(rid, deployer.address, deployer.address, await bond.getAddress(), 1000, 8000, 1000, ONE, 0, 0, "ipfs://demo"); const r = await f.rollups(rid); expect(r.registered).to.equal(true); const RS = await ethers.getContractFactory("AereRollupSettlementV2"); const s = RS.attach(r.settlement); expect(await s.DEFENSE_GRACE()).to.equal(GRACE); expect(await s.CHALLENGE_WINDOW()).to.equal(WINDOW); expect(await s.SINK()).to.equal(await sink.getAddress()); // V2-only surface exists expect(await s.isEpochSettled(1)).to.equal(false); }); it("enforces the sink floor and split invariants", async () => { const { f, bond, deployer } = await deployFactory(); await expect(f.registerRollup(ethers.id("a"), deployer.address, deployer.address, await bond.getAddress(), 500, 8500, 1000, ONE, 0, 0, "x")).to.be.revertedWithCustomError(f, "SinkBpsTooLow"); await expect(f.registerRollup(ethers.id("b"), deployer.address, deployer.address, await bond.getAddress(), 1000, 8000, 2000, ONE, 0, 0, "x")).to.be.revertedWithCustomError(f, "InvalidSplits"); }); }); }); // ═══════════════════════════════ COMPLIANCE POOL ═══════════════════════════════ describe("AereCompliancePoolV2 — F8 challenge bond (V1 reproduces, V2 corrects)", function () { this.timeout(300000); const CW = 100n; // short challenge/dismissal window for tests async function base() { const [foundation, challenger] = await ethers.getSigners(); const T = await ethers.getContractFactory("MockERC20Lending"); const token = await T.deploy("WAERE", "WAERE", 18); await token.waitForDeployment(); const V = await ethers.getContractFactory("MockPrivacyPoolVerifier"); const verifier = await V.deploy(); await verifier.waitForDeployment(); await token.mint(challenger.address, 1000n * ONE); return { foundation, challenger, token, verifier }; } async function deployV1() { const { foundation, challenger, token, verifier } = await base(); const P = await ethers.getContractFactory("AereCompliancePool"); const pool = await P.deploy(await token.getAddress(), ONE, foundation.address, await verifier.getAddress()); await pool.waitForDeployment(); await token.connect(challenger).approve(await pool.getAddress(), ethers.MaxUint256); return { pool, token, foundation, challenger }; } async function deployV2() { const { foundation, challenger, token, verifier } = await base(); const P = await ethers.getContractFactory("AereCompliancePoolV2"); const pool = await P.deploy(await token.getAddress(), ONE, foundation.address, await verifier.getAddress(), CW); await pool.waitForDeployment(); await token.connect(challenger).approve(await pool.getAddress(), ethers.MaxUint256); return { pool, token, foundation, challenger }; } it("V1 REPRODUCES: an UPHELD challenger never recovers the bond; a DISMISSED bond is burned; no refund fn", async () => { const { pool, token, foundation, challenger } = await deployV1(); const BOND = await pool.CHALLENGE_BOND(); // upheld branch: Foundation simply never dismisses -> bond stuck forever const rootA = ethers.id("assoc-A"); await pool.connect(foundation).proposeAssociationRoot(rootA); const balBefore = await token.balanceOf(challenger.address); await pool.connect(challenger).challengeAssociationRoot(rootA, "fraudulent"); expect(await token.balanceOf(challenger.address)).to.equal(balBefore - BOND); await expect(pool.publishAssociationRoot(rootA)).to.be.revertedWithCustomError(pool, "UnknownAssociationRoot"); const names = pool.interface.fragments.filter(f => f.type === "function").map(f => f.name); for (const n of ["refundChallenger", "reclaimUpheldChallenge", "reclaimBond", "withdrawBond"]) { expect(names).to.not.include(n); } expect(await token.balanceOf(challenger.address)).to.equal(balBefore - BOND); // permanently out // dismissed branch: bond burned to dEaD const rootB = ethers.id("assoc-B"); await pool.connect(foundation).proposeAssociationRoot(rootB); await pool.connect(challenger).challengeAssociationRoot(rootB, "reason"); const deadBefore = await token.balanceOf(DEAD); await pool.connect(foundation).dismissAssociationRootChallenge(rootB); expect(await token.balanceOf(DEAD)).to.equal(deadBefore + BOND); }); it("V2 CORRECTS: an UPHELD challenger is refunded + root rejected; a DISMISSED bond is still burned; timing guards hold", async () => { const { pool, token, foundation, challenger } = await deployV2(); const BOND = await pool.CHALLENGE_BOND(); // ── UPHELD: Foundation fails to dismiss in time -> challenger refunded, root rejected ── const rootA = ethers.id("assoc-A"); await pool.connect(foundation).proposeAssociationRoot(rootA); const balBefore = await token.balanceOf(challenger.address); await pool.connect(challenger).challengeAssociationRoot(rootA, "fraudulent"); expect(await token.balanceOf(challenger.address)).to.equal(balBefore - BOND); // reclaim before the window elapses reverts await expect(pool.reclaimUpheldChallenge(rootA)).to.be.revertedWithCustomError(pool, "DismissalWindowOpen"); // advance past the dismissal window await ethers.provider.send("evm_increaseTime", [Number(CW) + 5]); await mine(); // Foundation can no longer dismiss (too late) await expect(pool.connect(foundation).dismissAssociationRootChallenge(rootA)) .to.be.revertedWithCustomError(pool, "DismissalWindowClosed"); // anyone reclaims: bond refunded, root permanently rejected await pool.reclaimUpheldChallenge(rootA); expect(await token.balanceOf(challenger.address)).to.equal(balBefore); // WHOLE again expect(await pool.associationRootRejected(rootA)).to.equal(true); await expect(pool.publishAssociationRoot(rootA)).to.be.revertedWithCustomError(pool, "UnknownAssociationRoot"); // a rejected root cannot be re-challenged await expect(pool.connect(challenger).challengeAssociationRoot(rootA, "again")) .to.be.revertedWithCustomError(pool, "UnknownAssociationRoot"); // ── DISMISSED: provably-bad challenge still burns the bond ── const rootB = ethers.id("assoc-B"); await pool.connect(foundation).proposeAssociationRoot(rootB); await pool.connect(challenger).challengeAssociationRoot(rootB, "griefing a valid set"); const deadBefore = await token.balanceOf(DEAD); const balB = await token.balanceOf(challenger.address); await pool.connect(foundation).dismissAssociationRootChallenge(rootB); expect(await token.balanceOf(DEAD)).to.equal(deadBefore + BOND); // burned expect(await token.balanceOf(challenger.address)).to.equal(balB); // griefer NOT refunded // after a legit dismissal + a fresh window, the valid root can publish await ethers.provider.send("evm_increaseTime", [Number(CW) + 5]); await mine(); await pool.publishAssociationRoot(rootB); expect(await pool.isAssociationRootValid(rootB)).to.equal(true); }); });