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