// =============================================================================
// DEFENSE-IN-DEPTH: seeded randomized PROPERTY / STATEFUL-INVARIANT tests for the
// three corrected, audit-fixed V2 contracts the founder will grant governance
// authority and funds to.
//
//   1. AereGovernorV2   (robust quorum, FINDING 1) + AereGovLock vote source + TimelockController
//   2. AereRewardDistributorV2 (confiscation closed, FINDING 2)
//   3. AereHistoryStateRootAnchorV2 (double-verify removed, FINDING 4; SP1 verifier mocked)
//
// TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry). Foundry
// `forge` is not installed in this environment, and the repo builds through
// hardhat with OZ 4.9.6 resolved from node_modules plus viaIR + a cancun hardfork
// override, so a clean forge remapping/foundry.toml setup would fight the layout.
// Per the task's explicit fallback we drive long random sequences of actions with
// varied actors / amounts / timing via a SEEDED PRNG (reproducible), and assert
// the invariants after every step.
//
// REPRODUCIBILITY: every random choice comes from mulberry32(seed). The seed is
// printed at suite start and can be pinned with FUZZ_SEED. On any invariant break
// the failing action (campaign, step, actor, call, values, timing) is in the
// assertion message, giving a minimal repro.
//
// SCOPE: run this file in ISOLATION (the full hardhat suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
//     npx hardhat test test/invariant-audit-v2-property.test.js
// No deploy, no logic change, no live node.
// =============================================================================

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) : 0xA33E2800;
function rngFor(tag, campaign) {
  // Distinct, deterministic stream per (contract, 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 abi = ethers.AbiCoder.defaultAbiCoder();

async function setBal(addr, amountWei) {
  await network.provider.send("hardhat_setBalance", [addr, "0x" + amountWei.toString(16)]);
}
async function blockNum() {
  return await ethers.provider.getBlockNumber();
}
async function mineTo(target) {
  const cur = await blockNum();
  if (target > cur) {
    await network.provider.send("hardhat_mine", ["0x" + (target - cur).toString(16)]);
  }
}

// Iteration counts (env-overridable so a quick smoke can shrink them).
const N = {
  GOV_CAMPAIGNS: Number(process.env.GOV_CAMPAIGNS || 14),
  GOV_ROUNDS: Number(process.env.GOV_ROUNDS || 12),
  DIST_CAMPAIGNS: Number(process.env.DIST_CAMPAIGNS || 22),
  DIST_STEPS: Number(process.env.DIST_STEPS || 60),
  ANCHOR_CAMPAIGNS: Number(process.env.ANCHOR_CAMPAIGNS || 12),
  ANCHOR_STEPS: Number(process.env.ANCHOR_STEPS || 45),
};

// =============================================================================
// 1. AereGovernorV2 — robust quorum (FINDING 1)
// =============================================================================
describe("INVARIANT: AereGovernorV2 robust quorum (FINDING 1)", function () {
  this.timeout(0);

  const STATE = { Pending: 0, Active: 1, Canceled: 2, Defeated: 3, Succeeded: 4, Queued: 5, Expired: 6, Executed: 7 };
  const DAY = 24 * 60 * 60;
  let govLockFactory, govFactory, tlFactory, targetFactory;
  let signers;

  before(async function () {
    signers = await ethers.getSigners();
    govLockFactory = await ethers.getContractFactory("AereGovLock");
    govFactory = await ethers.getContractFactory("AereGovernorV2");
    tlFactory = await ethers.getContractFactory("TimelockController");
    targetFactory = await ethers.getContractFactory("GovSelfTestTarget");
    console.log(`      [gov] base seed 0x${BASE_SEED.toString(16)}, ${N.GOV_CAMPAIGNS} campaigns x ${N.GOV_ROUNDS} rounds`);
  });

  // clamp(fractional, floor, cap) exactly as the contract does.
  function expectedQuorum(supply, num, den, floor, cap) {
    const frac = (supply * num) / den;
    const capped = frac < cap ? frac : cap;
    return capped > floor ? capped : floor;
  }

  it("post-creation locks never inflate a proposal's quorum; bounds + turnout rule hold", async function () {
    let totalRounds = 0;
    let immunityChecks = 0;
    let votedRounds = 0;
    let succeeded = 0;
    let defeated = 0;
    let whaleRoundsWithContrast = 0;

    for (let c = 0; c < N.GOV_CAMPAIGNS; c++) {
      const rng = rngFor("gov", c);

      // Random-but-valid governor config for this campaign.
      const vd = ri(rng, 1, 6); // votingDelay (blocks)
      const period = ri(rng, 8, 16); // votingPeriod (blocks) — wide enough for all turnout votes
      await setBal(signers[0].address, E("100000000000")); // keep the deployer/gas-payer solvent
      const pct = ri(rng, 1, 30); // quorum %
      const lookback = ri(rng, 1, vd + 6); // may be < vd to exercise the max(lookback, vd) fold
      const floor = E(ri(rng, 1000, 200000));
      const cap = floor + E(ri(rng, 0, 2000000)); // >= floor

      const gl = await govLockFactory.deploy(0);
      await gl.waitForDeployment();
      const timelock = await tlFactory.deploy(0, [], [], signers[0].address);
      await timelock.waitForDeployment();
      const gov = await govFactory.deploy(
        await gl.getAddress(),
        await timelock.getAddress(),
        vd, period, 0 /* proposalThreshold */, pct,
        floor, cap, lookback
      );
      await gov.waitForDeployment();
      const target = await targetFactory.deploy();
      await target.waitForDeployment();
      await (await target.transferOwnership(await timelock.getAddress())).wait();
      {
        const P = await timelock.PROPOSER_ROLE();
        const Ex = await timelock.EXECUTOR_ROLE();
        await (await timelock.grantRole(P, await gov.getAddress())).wait();
        await (await timelock.grantRole(Ex, await gov.getAddress())).wait();
      }
      expect(await gov.quorumDenominator()).to.equal(100n);
      const num = BigInt(pct);
      const den = 100n;

      // Actor set: three persistent honest voters, one transient (short-lock) voter, one silent whale.
      const persistent = [signers[1], signers[2], signers[3]];
      const transient = signers[4];
      const whale = signers[5];
      for (const s of [...persistent, transient, whale]) {
        await setBal(s.address, E("100000000000")); // 100B fake AERE headroom
      }

      // Honest setup: persistent voters lock (far-future) and self-delegate.
      for (const s of persistent) {
        const amt = E(ri(rng, 1000, 500000));
        const unlock = (await time.latest()) + 1000 * DAY;
        await (await gl.connect(s).lock(amt, unlock, { value: amt })).wait();
        await (await gl.connect(s).delegate(s.address)).wait();
      }

      for (let r = 0; r < N.GOV_ROUNDS; r++) {
        // ---- pre-proposal churn: honest top-up locks + a transient lock/unlock ----
        if (chance(rng, 0.5)) {
          const s = pick(rng, persistent);
          const amt = E(ri(rng, 1000, 200000));
          const unlock = (await time.latest()) + 1000 * DAY;
          await (await gl.connect(s).lock(amt, unlock, { value: amt })).wait();
        }
        // Transient locker: lock short, and try to withdraw if a previous short lock matured.
        try {
          const [tAmt] = await gl.locks(transient.address);
          if (tAmt === 0n && chance(rng, 0.5)) {
            const amt = E(ri(rng, 1000, 50000));
            const unlock = (await time.latest()) + ri(rng, 10, 60);
            await (await gl.connect(transient).lock(amt, unlock, { value: amt })).wait();
            await (await gl.connect(transient).delegate(transient.address)).wait();
          } else if (tAmt > 0n) {
            await time.increase(ri(rng, 1, 120)); // maybe cross the unlock time
            const unlockT = await gl.unlockTimeOf(transient.address);
            if (BigInt(await time.latest()) >= unlockT) {
              await (await gl.connect(transient).withdraw()).wait(); // UNLOCK action
            }
          }
        } catch (_) { /* best-effort churn */ }

        // Mine a margin so all honest locks are strictly before the lookback point.
        await network.provider.send("hardhat_mine", ["0x" + (lookback + vd + 3).toString(16)]);

        // ---- create the proposal ----
        const targets = [await target.getAddress()];
        const values = [0n];
        const calldatas = [target.interface.encodeFunctionData("setValue", [ri(rng, 1, 1e9)])];
        const description = `c${c}r${r}-${Math.floor(rng() * 1e9)}`;
        const descHash = ethers.id(description);
        const proposalId = await gov.hashProposal(targets, values, calldatas, descHash);
        const proposer = persistent[0];
        await (await gov.connect(proposer).propose(targets, values, calldatas, description)).wait();

        const snapshot = await gov.proposalSnapshot(proposalId); // = C + vd
        const deadline = await gov.proposalDeadline(proposalId);
        const C = await blockNum(); // propose tx block

        // Pre-whale reference supply, measured BEFORE the whale exists (C-1 < C).
        const refSupply = await gl.getPastTotalSupply(C - 1);

        // ---- ADVERSARY: silent whale reactively locks AFTER creation (blocks in (C, snapshot]) ----
        const whaleAmount = chance(rng, 0.75) ? E(ri(rng, 1000, 2000000000)) : 0n;
        if (whaleAmount > 0n) {
          const curBefore = await blockNum();
          expect(curBefore, "whale must be able to lock before snapshot").to.be.lessThan(Number(snapshot));
          const unlock = (await time.latest()) + 1000 * DAY;
          await (await gl.connect(whale).lock(whaleAmount, unlock, { value: whaleAmount })).wait();
          // The whale stays SILENT: it never delegates-to-vote and never casts a vote.
          // (Delegation is irrelevant to total-supply denominator anyway.)
        }

        // Mine to Active (snapshot + 1) so quorum(snapshot) and votes are queryable.
        await mineTo(Number(snapshot) + 1);

        // Denominator the contract actually uses.
        const maxLb = BigInt(Math.max(lookback, vd));
        const lookbackPoint = snapshot > maxLb ? snapshot - maxLb : 0n;
        const denomSupply = await gl.getPastTotalSupply(lookbackPoint);
        const Q = await gov.quorum(snapshot);

        const ctx = `campaign ${c} round ${r} | vd=${vd} lb=${lookback} pct=${pct} floor=${floor} cap=${cap} whale=${whaleAmount}`;

        // ---- INVARIANT A (core immunity): the quorum denominator can never rise
        //      above the pre-proposal honest supply, no matter how much the whale
        //      locked after creation. refSupply was measured before the whale existed.
        expect(denomSupply, `[gov IMMUNITY] denominator inflated by reactive whale — ${ctx}`)
          .to.be.lte(refSupply);

        // ---- INVARIANT (mechanism + bounds): Q is exactly the clamped fraction of
        //      that immune denominator, and always inside [floor, cap].
        expect(Q, `[gov MECH] quorum != clamp(denom) — ${ctx} denom=${denomSupply}`)
          .to.equal(expectedQuorum(denomSupply, num, den, floor, cap));
        expect(Q, `[gov BOUND-lo] quorum below floor — ${ctx}`).to.be.gte(floor);
        expect(Q, `[gov BOUND-hi] quorum above cap — ${ctx}`).to.be.lte(cap);
        immunityChecks++;

        // ---- CONTRAST: a naive GovernorVotesQuorumFraction would use the live
        //      (whale-inflated) supply at the snapshot; show the whale genuinely
        //      inflates that naive denominator while Q is unmoved.
        if (whaleAmount >= E(1000)) {
          const naiveSupply = await gl.getPastTotalSupply(snapshot);
          expect(naiveSupply, `[gov CONTRAST] whale did not inflate naive supply — ${ctx}`)
            .to.be.gt(denomSupply);
          whaleRoundsWithContrast++;
        }

        // ---- INVARIANT C (turnout rule): drive a subset of rounds to completion.
        if (chance(rng, 0.5)) {
          // Random honest turnout: each persistent voter independently votes For.
          // Each castVote mines a block; guard against exhausting the voting window
          // (a short random votingPeriod) so we only ever vote while Active. The
          // turnout predicate below reads the ACTUAL on-chain tally, so a skipped
          // voter simply lowers forVotes — the invariant assertion stays exact.
          const voters = persistent.filter(() => chance(rng, 0.6));
          for (const v of voters) {
            if (Number(await gov.state(proposalId)) !== STATE.Active) break;
            const w = await gov.getVotes(v.address, snapshot); // weight at snapshot (delegated + locked)
            if (w > 0n) await (await gov.connect(v).castVote(proposalId, 1)).wait(); // 1 = For
          }
          // transient voter may also vote if it currently has weight and window is open.
          if (chance(rng, 0.4) && Number(await gov.state(proposalId)) === STATE.Active) {
            const w = await gov.getVotes(transient.address, snapshot);
            if (w > 0n) await (await gov.connect(transient).castVote(proposalId, 1)).wait();
          }

          await mineTo(Number(deadline) + 1);

          const [againstV, forV, abstainV] = await gov.proposalVotes(proposalId);
          const st = Number(await gov.state(proposalId));
          const quorumReached = forV + abstainV >= Q;
          const majority = forV > againstV;
          const expectSucceeded = quorumReached && majority;

          expect(st === STATE.Succeeded, `[gov TURNOUT] succeeded mismatch — ${ctx} for=${forV} Q=${Q} st=${st}`)
            .to.equal(expectSucceeded);
          expect(st === STATE.Defeated || st === STATE.Succeeded, `[gov STATE] not resolved — ${ctx} st=${st}`)
            .to.equal(true);
          // A genuinely low-turnout proposal (for below the floor) can NEVER reach quorum,
          // because Q >= floor. Assert that direction explicitly.
          if (forV + abstainV < floor) {
            expect(quorumReached, `[gov FLOOR] sub-floor turnout reached quorum — ${ctx}`).to.equal(false);
            expect(st, `[gov FLOOR] sub-floor turnout not defeated — ${ctx}`).to.equal(STATE.Defeated);
          }
          if (st === STATE.Succeeded) succeeded++; else defeated++;
          votedRounds++;
        }
        totalRounds++;
      }
    }

    console.log(`      [gov] rounds=${totalRounds} immunityChecks=${immunityChecks} whaleContrast=${whaleRoundsWithContrast} voted=${votedRounds} (succeeded=${succeeded} defeated=${defeated})`);
    expect(immunityChecks).to.be.greaterThan(100); // hundreds+ of quorum-immunity assertions
    expect(votedRounds).to.be.greaterThan(20);
    expect(succeeded).to.be.greaterThan(0);
    expect(defeated).to.be.greaterThan(0);
  });

  // Directed end-to-end sanity: a healthy proposal still queues + executes through
  // the (untouched) TimelockController — the fix does not break normal governance.
  it("healthy proposal still executes end-to-end through the timelock", async function () {
    const gl = await govLockFactory.deploy(0);
    await gl.waitForDeployment();
    const timelock = await tlFactory.deploy(0, [], [], signers[0].address);
    await timelock.waitForDeployment();
    const gov = await govFactory.deploy(
      await gl.getAddress(), await timelock.getAddress(),
      2, 6, 0, 10, E("100000"), E("500000"), 4
    );
    await gov.waitForDeployment();
    const target = await targetFactory.deploy();
    await target.waitForDeployment();
    await (await target.transferOwnership(await timelock.getAddress())).wait();
    await (await timelock.grantRole(await timelock.PROPOSER_ROLE(), await gov.getAddress())).wait();
    await (await timelock.grantRole(await timelock.EXECUTOR_ROLE(), await gov.getAddress())).wait();

    const honest = signers[1];
    await setBal(honest.address, E("10000000"));
    const LOCKED = E("3000000"); // 10% = 300k inside [100k,500k]
    await (await gl.connect(honest).lock(LOCKED, (await time.latest()) + 1000 * DAY, { value: LOCKED })).wait();
    await (await gl.connect(honest).delegate(honest.address)).wait();
    await network.provider.send("hardhat_mine", ["0x10"]);

    const targets = [await target.getAddress()];
    const values = [0n];
    const calldatas = [target.interface.encodeFunctionData("setValue", [42])];
    const description = "healthy e2e";
    const descHash = ethers.id(description);
    const id = await gov.hashProposal(targets, values, calldatas, descHash);
    await (await gov.connect(honest).propose(targets, values, calldatas, description)).wait();
    const snap = await gov.proposalSnapshot(id);
    const dl = await gov.proposalDeadline(id);
    await mineTo(Number(snap) + 1);
    await (await gov.connect(honest).castVote(id, 1)).wait();
    await mineTo(Number(dl) + 1);
    expect(Number(await gov.state(id))).to.equal(STATE.Succeeded);
    await (await gov.queue(targets, values, calldatas, descHash)).wait();
    await (await gov.execute(targets, values, calldatas, descHash)).wait();
    expect(Number(await gov.state(id))).to.equal(STATE.Executed);
    expect(await target.value()).to.equal(42n);
  });
});

// =============================================================================
// 2. AereRewardDistributorV2 — confiscation closed (FINDING 2)
// =============================================================================
describe("INVARIANT: AereRewardDistributorV2 confiscation closed (FINDING 2)", function () {
  this.timeout(0);

  let signers, waereF, saereF, sinkF, distF;

  before(async function () {
    signers = await ethers.getSigners();
    waereF = await ethers.getContractFactory("WAERE");
    saereF = await ethers.getContractFactory("MockSAERE");
    sinkF = await ethers.getContractFactory("MockSink");
    distF = await ethers.getContractFactory("AereRewardDistributorV2");
    console.log(`      [dist] base seed 0x${BASE_SEED.toString(16)}, ${N.DIST_CAMPAIGNS} campaigns x ${N.DIST_STEPS} steps`);
  });

  /* ------------------------- merkle (OZ StandardMerkleTree leaf) ------------ */
  function leafHash(account, total) {
    return ethers.keccak256(ethers.keccak256(abi.encode(["address", "uint256"], [account, total])));
  }
  function hashPair(a, b) {
    return BigInt(a) < BigInt(b)
      ? ethers.keccak256(ethers.concat([a, b]))
      : ethers.keccak256(ethers.concat([b, a]));
  }
  function buildLayers(leaves) {
    const layers = [leaves.slice()];
    while (layers[layers.length - 1].length > 1) {
      const prev = layers[layers.length - 1];
      const next = [];
      for (let i = 0; i < prev.length; i += 2) {
        if (i + 1 < prev.length) next.push(hashPair(prev[i], prev[i + 1]));
        else next.push(prev[i]);
      }
      layers.push(next);
    }
    return layers;
  }
  function getProof(layers, index) {
    const proof = [];
    let idx = index;
    for (let l = 0; l < layers.length - 1; l++) {
      const layer = layers[l];
      const sib = idx ^ 1;
      if (sib < layer.length) proof.push(layer[sib]);
      idx = Math.floor(idx / 2);
    }
    return proof;
  }
  function makeTree(entries) {
    const leaves = entries.map((e) => leafHash(e.account, e.total));
    const layers = buildLayers(leaves);
    const indexOf = {};
    entries.forEach((e, i) => (indexOf[e.account.toLowerCase()] = i));
    return { root: layers[layers.length - 1][0], proofFor: (a) => getProof(layers, indexOf[a.toLowerCase()]) };
  }

  // JS mirror of the contract's _vested().
  function vestedJS(total, atTime, start, cliff, vesting) {
    if (atTime < start + cliff) return 0n;
    if (atTime >= start + vesting) return total;
    return (total * BigInt(atTime - start)) / BigInt(vesting);
  }

  it("clawback can never breach the reserve; no over/double/excluded claim; legit dust clawback works", async function () {
    let steps = 0;
    let claims = 0, clawbacks = 0, excludedBlocks = 0, pauseClawbackBlocks = 0, dustRecovered = 0;

    for (let c = 0; c < N.DIST_CAMPAIGNS; c++) {
      const rng = rngFor("dist", c);

      const waere = await waereF.deploy();
      const saere = await saereF.deploy(await waere.getAddress());
      const sink = await sinkF.deploy();
      const dist = await distF.deploy(await waere.getAddress(), await saere.getAddress(), await sink.getAddress());
      await dist.waitForDeployment();
      const distAddr = await dist.getAddress();
      const owner = signers[0];
      const relayer = signers[19];
      // Keep the owner (funds the reserve + pays gas) and relayer (pays claim gas)
      // solvent across all campaigns; reserves permanently leave the owner to claimants.
      await setBal(owner.address, E("100000000000"));
      await setBal(relayer.address, E("100000000000"));

      // Random claimant set + entitlements. Some addresses are NON-claimants (used
      // to exercise BadProof).
      const nClaim = ri(rng, 2, 6);
      const claimants = [];
      const totals = {};
      for (let i = 0; i < nClaim; i++) {
        const acc = signers[3 + i];
        const t = E(ri(rng, 1, 1000));
        claimants.push(acc);
        totals[acc.address] = t;
      }
      const nonClaimant = signers[3 + nClaim]; // not in the tree
      const tree = makeTree(claimants.map((a) => ({ account: a.address, total: totals[a.address] })));

      // HONEST totalAllocated = exact sum of published leaves (the documented dependency).
      let totalAllocated = 0n;
      for (const a of claimants) totalAllocated += totals[a.address];

      const cliff = ri(rng, 0, 500);
      const vesting = cliff + ri(rng, 1, 5000);
      const nowTs = await time.latest();
      const expiry = nowTs + vesting + ri(rng, 100, 100000); // must outlast vesting
      const deliverAsSAERE = chance(rng, 0.5);
      await (await dist.publishRoot(tree.root, cliff, vesting, expiry, deliverAsSAERE, totalAllocated)).wait();
      const start = Number(await dist.rootPublishedAt());

      // Fund honestly: allocation + random overfunded dust (>= 0).
      const dust0 = E(ri(rng, 0, 200));
      await owner.sendTransaction({ to: distAddr, value: totalAllocated + dust0 });

      // JS shadow state.
      const claimedJS = {};
      for (const a of claimants) claimedJS[a.address] = 0n;
      let totalClaimedJS = 0n;
      const excludedJS = {};
      let paused = false;
      let curTime = Number(await time.latest());
      let prevTotalClaimed = 0n;

      async function advance(delta) {
        curTime += delta;
        await time.setNextBlockTimestamp(curTime);
      }

      async function assertInvariants(tag) {
        const bal = await ethers.provider.getBalance(distAddr);
        const onTotalClaimed = await dist.totalClaimed();
        const onAllocated = await dist.totalAllocated();
        const reserved = onAllocated > onTotalClaimed ? onAllocated - onTotalClaimed : 0n;

        // CORE confiscation invariant: a claimant's vested-but-unclaimed entitlement
        // is never sweepable — balance always covers the reserve.
        expect(bal, `[dist RESERVE] balance < reserved after ${tag} (c${c} s${steps})`).to.be.gte(reserved);
        // Accounting integrity.
        expect(onTotalClaimed, `[dist CAP] totalClaimed > totalAllocated after ${tag} (c${c} s${steps})`).to.be.lte(onAllocated);
        expect(onAllocated, `[dist FROZEN] totalAllocated drifted after ${tag} (c${c} s${steps})`).to.equal(totalAllocated);
        expect(onTotalClaimed, `[dist SHADOW] on-chain totalClaimed != shadow after ${tag}`).to.equal(totalClaimedJS);
        expect(onTotalClaimed, `[dist MONO] totalClaimed decreased after ${tag}`).to.be.gte(prevTotalClaimed);
        prevTotalClaimed = onTotalClaimed;
        // Per-leaf: never claim more than entitlement, on-chain matches shadow.
        for (const a of claimants) {
          const onClaimed = await dist.claimed(a.address);
          expect(onClaimed, `[dist LEAF] claimed>entitlement ${a.address} after ${tag}`).to.be.lte(totals[a.address]);
          expect(onClaimed, `[dist LEAF-SHADOW] mismatch ${a.address} after ${tag}`).to.equal(claimedJS[a.address]);
        }
      }

      await assertInvariants("publish+fund");

      for (let s = 0; s < N.DIST_STEPS; s++) {
        const action = pick(rng, [
          "claim", "claim", "claim", "setExcluded", "pause", "unpause", "clawback", "clawback", "advance",
        ]);

        if (action === "advance") {
          await advance(ri(rng, 1, Math.max(2, Math.floor(vesting / 3))));
          // advancing time needs a block to take effect for subsequent reads; mine one via a no-op tx-free jump
          await network.provider.send("evm_mine");
        } else if (action === "claim") {
          // Usually a real claimant; occasionally the non-claimant (BadProof).
          const useBad = chance(rng, 0.12);
          const acc = useBad ? nonClaimant : pick(rng, claimants);
          const total = useBad ? E(ri(rng, 1, 1000)) : totals[acc.address];
          const proof = useBad ? tree.proofFor(claimants[0].address) : tree.proofFor(acc.address);
          await advance(ri(rng, 1, Math.max(2, Math.floor(vesting / 4))));

          // Predict.
          const vested = useBad ? 0n : vestedJS(total, curTime, start, cliff, vesting);
          const already = useBad ? 0n : claimedJS[acc.address];
          let expectRevert = null;
          if (paused) expectRevert = "Pausable: paused";
          else if (!useBad && excludedJS[acc.address]) expectRevert = "IsExcluded";
          else if (useBad) expectRevert = "BadProof";
          else if (vested <= already) expectRevert = "NothingToClaim";

          if (expectRevert) {
            if (expectRevert === "Pausable: paused") {
              await expect(dist.connect(relayer).claim(acc.address, total, proof)).to.be.revertedWith("Pausable: paused");
            } else {
              await expect(dist.connect(relayer).claim(acc.address, total, proof)).to.be.revertedWithCustomError(dist, expectRevert);
            }
            if (expectRevert === "IsExcluded") excludedBlocks++;
          } else {
            const amount = vested - already;
            const before = await ethers.provider.getBalance(distAddr);
            const accBefore = deliverAsSAERE ? 0n : await ethers.provider.getBalance(acc.address);
            const sharesBefore = deliverAsSAERE ? await saere.balanceOf(acc.address) : 0n;
            await (await dist.connect(relayer).claim(acc.address, total, proof)).wait();
            const after = await ethers.provider.getBalance(distAddr);
            // Contract native balance decreases by exactly `amount` on BOTH delivery paths.
            expect(before - after, `[dist DELIVER] contract delta != amount (c${c} s${s})`).to.equal(amount);
            if (deliverAsSAERE) {
              expect(await saere.balanceOf(acc.address), `[dist SAERE] no shares minted (c${c} s${s})`).to.be.gt(sharesBefore);
            } else {
              // relayer paid gas, so the claimant's balance rises by exactly `amount`.
              expect((await ethers.provider.getBalance(acc.address)) - accBefore, `[dist LIQUID] payout != amount (c${c} s${s})`).to.equal(amount);
            }
            claimedJS[acc.address] = vested;
            totalClaimedJS += amount;
            claims++;
          }
        } else if (action === "setExcluded") {
          const acc = pick(rng, claimants);
          const val = chance(rng, 0.7);
          const shouldRevert = val && claimedJS[acc.address] > 0n;
          if (shouldRevert) {
            await expect(dist.setExcluded(acc.address, true)).to.be.revertedWithCustomError(dist, "AlreadyClaimed");
          } else {
            await (await dist.setExcluded(acc.address, val)).wait();
            excludedJS[acc.address] = val;
          }
        } else if (action === "pause") {
          if (paused) {
            await expect(dist.pause()).to.be.revertedWith("Pausable: paused");
          } else {
            await (await dist.pause()).wait();
            paused = true;
          }
        } else if (action === "unpause") {
          if (!paused) {
            await expect(dist.unpause()).to.be.revertedWith("Pausable: not paused");
          } else {
            await (await dist.unpause()).wait();
            paused = false;
          }
        } else if (action === "clawback") {
          const toSink = chance(rng, 0.5);
          await advance(ri(rng, 1, Math.max(2, Math.floor(vesting / 2))));
          const bal = await ethers.provider.getBalance(distAddr);
          const reserved = totalAllocated > totalClaimedJS ? totalAllocated - totalClaimedJS : 0n;
          let expectRevert = null;
          if (paused) expectRevert = "Pausable: paused";
          else if (curTime <= expiry) expectRevert = "ClawbackTooEarly";
          else if (bal <= reserved) expectRevert = "NothingToClawback";

          if (expectRevert) {
            if (expectRevert === "Pausable: paused") {
              await expect(dist.clawbackUnclaimed(toSink)).to.be.revertedWith("Pausable: paused");
              if (curTime > expiry) pauseClawbackBlocks++; // paused blocks a post-expiry sweep
            } else {
              await expect(dist.clawbackUnclaimed(toSink)).to.be.revertedWithCustomError(dist, expectRevert);
            }
          } else {
            const amount = bal - reserved;
            const dest = toSink ? await sink.getAddress() : owner.address;
            const destBefore = await ethers.provider.getBalance(dest);
            const tx = await dist.clawbackUnclaimed(toSink);
            const rc = await tx.wait();
            const after = await ethers.provider.getBalance(distAddr);
            // Contract keeps EXACTLY the reserve; only overfunded dust left.
            expect(after, `[dist CLAWBACK] contract not left at reserve (c${c} s${s})`).to.equal(reserved);
            if (toSink) {
              expect((await ethers.provider.getBalance(dest)) - destBefore, `[dist CLAWBACK-DEST] sink delta wrong`).to.equal(amount);
            } else {
              // owner pays gas; net = amount - gasCost.
              const gas = rc.gasUsed * rc.gasPrice;
              expect((await ethers.provider.getBalance(dest)) - destBefore, `[dist CLAWBACK-DEST] owner delta wrong`).to.equal(amount - gas);
            }
            dustRecovered++;
            clawbacks++;
          }
        }

        await assertInvariants(action);
        // Resync the clock: non-advance actions (setExcluded/pause/unpause and
        // reverting txs) auto-bump the block timestamp by 1 without touching
        // curTime, so re-anchor curTime to the real chain head each step. This
        // keeps setNextBlockTimestamp strictly increasing and keeps claim/clawback
        // vesting predictions exact.
        curTime = Number(await time.latest());
        steps++;
      }

      // ---- Campaign finale part A: GUARANTEED pause-then-clawback residual probe ----
      // The exact FINDING-2 residual attack: owner pauses, jumps PAST expiry, then
      // tries to sweep the balance (which still reserves every claimant's vested-but-
      // unclaimed entitlement). Both sink and owner destinations MUST be blocked by
      // whenNotPaused, and the contract balance MUST be untouched.
      if (!paused) { await (await dist.pause()).wait(); paused = true; }
      curTime = Math.max(Number(await time.latest()) + 1, expiry + 1 + ri(rng, 1, 1000));
      await time.setNextBlockTimestamp(curTime);
      await network.provider.send("evm_mine");
      {
        const balBefore = await ethers.provider.getBalance(distAddr);
        await expect(dist.clawbackUnclaimed(false), `[dist PAUSE-CLAWBACK owner]`).to.be.revertedWith("Pausable: paused");
        await expect(dist.clawbackUnclaimed(true), `[dist PAUSE-CLAWBACK sink]`).to.be.revertedWith("Pausable: paused");
        expect(await ethers.provider.getBalance(distAddr), `[dist PAUSE-CLAWBACK] balance moved while paused`).to.equal(balBefore);
        pauseClawbackBlocks++;
      }
      await assertInvariants("paused-clawback-blocked");

      // ---- Campaign finale part B: legit post-expiry dust recovery still works ----
      await (await dist.unpause()).wait(); paused = false;
      const balF = await ethers.provider.getBalance(distAddr);
      const reservedF = totalAllocated > totalClaimedJS ? totalAllocated - totalClaimedJS : 0n;
      if (balF > reservedF) {
        await (await dist.clawbackUnclaimed(true)).wait();
        expect(await ethers.provider.getBalance(distAddr), `[dist FINALE] not at reserve`).to.equal(reservedF);
        dustRecovered++;
      } else {
        await expect(dist.clawbackUnclaimed(true)).to.be.revertedWithCustomError(dist, "NothingToClawback");
      }
      // The full reserve (every claimant's remaining entitlement) survives forever.
      await assertInvariants("finale");
    }

    console.log(`      [dist] steps=${steps} claims=${claims} clawbacks=${clawbacks} dustSweeps=${dustRecovered} excludedBlocked=${excludedBlocks} pausedClawbackBlocked=${pauseClawbackBlocks}`);
    expect(steps).to.be.greaterThan(500);
    expect(claims).to.be.greaterThan(50);
    expect(dustRecovered).to.be.greaterThan(0);
    expect(pauseClawbackBlocks).to.be.greaterThan(0); // the pause-then-clawback variant was exercised & blocked
  });
});

// =============================================================================
// 3. AereHistoryStateRootAnchorV2 — double-verify removed (FINDING 4)
// =============================================================================
describe("INVARIANT: AereHistoryStateRootAnchorV2 single-verify (FINDING 4)", function () {
  this.timeout(0);

  const { encodeHeader } = require("./header-rlp");
  const genesis = require("./fixtures/aere-genesis-header.json");
  let verifierF, anchorF;

  before(async function () {
    verifierF = await ethers.getContractFactory("CountingStorageProofVerifier");
    anchorF = await ethers.getContractFactory("MockHistoryAnchorV2");
    console.log(`      [anchor] base seed 0x${BASE_SEED.toString(16)}, ${N.ANCHOR_CAMPAIGNS} campaigns x ${N.ANCHOR_STEPS} steps`);
  });

  function publicValues({ chainId, blockNumber, stateRoot, account, slot, value }) {
    return abi.encode(
      ["uint256", "uint256", "bytes32", "address", "bytes32", "bytes32"],
      [chainId, blockNumber, stateRoot, account, slot, value]
    );
  }
  const rand32 = (rng) => "0x" + Array.from({ length: 32 }, () => ri(rng, 0, 255).toString(16).padStart(2, "0")).join("");
  const randAddr = (rng) => ethers.getAddress("0x" + Array.from({ length: 20 }, () => ri(rng, 0, 255).toString(16).padStart(2, "0")).join(""));

  it("exactly one SP1 verify per prove; only anchored+bound roots record; verify() never called", async function () {
    let steps = 0, successes = 0, notAnchored = 0, rootMismatch = 0, badLen = 0, invalidProof = 0, crossBlock = 0;

    for (let c = 0; c < N.ANCHOR_CAMPAIGNS; c++) {
      const rng = rngFor("anchor", c);
      const verifier = await verifierF.deploy();
      await verifier.waitForDeployment();
      const anchor = await anchorF.deploy(await verifier.getAddress());
      await anchor.waitForDeployment();

      // Anchor a random set of blocks, each with a DISTINCT well-formed state root.
      // Trick (from the existing suite): overwrite only the genesis header's
      // stateRoot, re-encode -> new keccak canonical hash -> inject -> anchor.
      const anchored = {}; // blockNumber(string) -> root
      const anchoredNums = [];
      const nAnchor = ri(rng, 1, 5);
      const usedRoots = new Set();
      for (let i = 0; i < nAnchor; i++) {
        const bn = BigInt(ri(rng, 1, 2000000));
        if (anchored[bn.toString()]) continue;
        let root = rand32(rng);
        while (usedRoots.has(root)) root = rand32(rng);
        usedRoots.add(root);
        const header = encodeHeader({ ...genesis, stateRoot: root });
        const hash = ethers.keccak256(header);
        await (await anchor.setHistoryBlockHash(bn, hash)).wait();
        await (await anchor.anchorHistoricalHeader(bn, header)).wait();
        expect(await anchor.anchoredStateRoot(bn)).to.equal(root);
        anchored[bn.toString()] = root;
        anchoredNums.push(bn);
      }

      let expectedSubmitCalls = 0n;
      let shouldValidate = true;

      for (let s = 0; s < N.ANCHOR_STEPS; s++) {
        const action = pick(rng, ["prove", "prove", "prove", "prove", "toggleValidate", "anchorMore"]);

        if (action === "toggleValidate") {
          shouldValidate = chance(rng, 0.5);
          await (await verifier.setValidate(shouldValidate)).wait();
          expect(await verifier.submitCalls(), `[anchor CALLS] setValidate changed submitCalls`).to.equal(expectedSubmitCalls);
          steps++;
          continue;
        }
        if (action === "anchorMore" && chance(rng, 0.7)) {
          const bn = BigInt(ri(rng, 1, 2000000));
          if (!anchored[bn.toString()]) {
            let root = rand32(rng);
            while (usedRoots.has(root)) root = rand32(rng);
            usedRoots.add(root);
            const header = encodeHeader({ ...genesis, stateRoot: root });
            const hash = ethers.keccak256(header);
            await (await anchor.setHistoryBlockHash(bn, hash)).wait();
            await (await anchor.anchorHistoricalHeader(bn, header)).wait();
            anchored[bn.toString()] = root;
            anchoredNums.push(bn);
          }
          expect(await verifier.submitCalls(), `[anchor CALLS] anchoring changed submitCalls`).to.equal(expectedSubmitCalls);
          steps++;
          continue;
        }

        // ---- build a random proveAgainstAnchor call ----
        let mode = pick(rng, ["valid", "valid", "wrongRoot", "notAnchored", "badLen", "crossBlock"]);
        const proof = "0x";
        let pv;
        const account = randAddr(rng);
        const slot = rand32(rng);
        const value = rand32(rng);
        const chainId = BigInt(ri(rng, 1, 5000));

        // Degrade modes that need anchored blocks when none / too few exist yet.
        if ((mode === "valid" || mode === "wrongRoot" || mode === "crossBlock") && anchoredNums.length === 0) {
          mode = chance(rng, 0.5) ? "badLen" : "notAnchored";
        }
        if (mode === "crossBlock" && anchoredNums.length < 2) mode = "valid";

        if (mode === "badLen") {
          const lens = [0, 32, 64, 96, 128, 160, 224, 256, 191, 193, 320]; // all != 192
          const len = pick(rng, lens);
          pv = "0x" + "11".repeat(len);
          await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor BADLEN len=${len}] (c${c} s${s})`)
            .to.be.revertedWithCustomError(anchor, "BadPublicValuesLength");
          expect(await verifier.submitCalls(), `[anchor CALLS] badLen moved submitCalls`).to.equal(expectedSubmitCalls);
          badLen++; steps++;
          continue;
        }

        if (mode === "notAnchored") {
          let naBn;
          do { naBn = BigInt(ri(rng, 2000001, 4000000)); } while (anchored[naBn.toString()]);
          pv = publicValues({ chainId, blockNumber: naBn, stateRoot: rand32(rng), account, slot, value });
          await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor NOTANCHORED bn=${naBn}] (c${c} s${s})`)
            .to.be.revertedWithCustomError(anchor, "NotAnchored");
          expect(await verifier.submitCalls(), `[anchor CALLS] NotAnchored moved submitCalls`).to.equal(expectedSubmitCalls);
          notAnchored++; steps++;
          continue;
        }

        // The remaining modes (crossBlock/wrongRoot/valid) reach the mock verifier's
        // submit. Its FIRST gate is shouldValidate (reverts "InvalidProof") BEFORE the
        // root-binding check, so a submit-reaching call with validation OFF reverts
        // "InvalidProof" regardless of the root; with validation ON, a root mismatch
        // reverts "StateRootMismatch". Either way submitCalls must NOT advance and
        // nothing is recorded.
        if (mode === "crossBlock") {
          const a = pick(rng, anchoredNums);
          let b = pick(rng, anchoredNums), guard = 0;
          while (anchored[b.toString()] === anchored[a.toString()] && guard++ < 20) b = pick(rng, anchoredNums);
          if (anchored[b.toString()] === anchored[a.toString()]) {
            mode = "valid"; // could not find a differing block; fall through to a valid prove
          } else {
            // Proof genuinely valid for block A's root, redirected at block B (different anchored root).
            pv = publicValues({ chainId, blockNumber: b, stateRoot: anchored[a.toString()], account, slot, value });
            const reason = shouldValidate ? "StateRootMismatch" : "InvalidProof";
            await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor CROSSBLOCK a=${a} b=${b} validate=${shouldValidate}] (c${c} s${s})`)
              .to.be.revertedWith(reason);
            expect(await verifier.submitCalls(), `[anchor CALLS] crossBlock moved submitCalls`).to.equal(expectedSubmitCalls);
            expect(await verifier.recorded(ethers.keccak256(pv)), `[anchor RECORD] crossBlock recorded`).to.equal(false);
            crossBlock++; steps++;
            continue;
          }
        }

        const bn = pick(rng, anchoredNums);
        const trueRoot = anchored[bn.toString()];

        if (mode === "wrongRoot") {
          let bad = rand32(rng);
          while (bad === trueRoot) bad = rand32(rng);
          pv = publicValues({ chainId, blockNumber: bn, stateRoot: bad, account, slot, value });
          const reason = shouldValidate ? "StateRootMismatch" : "InvalidProof";
          await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor WRONGROOT bn=${bn} validate=${shouldValidate}] (c${c} s${s})`)
            .to.be.revertedWith(reason);
          expect(await verifier.submitCalls(), `[anchor CALLS] wrongRoot moved submitCalls`).to.equal(expectedSubmitCalls);
          expect(await verifier.recorded(ethers.keccak256(pv))).to.equal(false);
          rootMismatch++; steps++;
          continue;
        }

        // mode === "valid": committed root == anchored root.
        pv = publicValues({ chainId, blockNumber: bn, stateRoot: trueRoot, account, slot, value });
        if (!shouldValidate) {
          // Invalid underlying SP1 proof: the single verification still gates recording.
          await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor INVALIDPROOF bn=${bn}] (c${c} s${s})`)
            .to.be.revertedWith("InvalidProof");
          expect(await verifier.submitCalls(), `[anchor CALLS] invalidProof moved submitCalls`).to.equal(expectedSubmitCalls);
          expect(await verifier.recorded(ethers.keccak256(pv))).to.equal(false);
          invalidProof++; steps++;
          continue;
        }

        // Happy path: EXACTLY ONE verification, records, emits with the ANCHORED root.
        await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor VALID bn=${bn}] (c${c} s${s})`)
          .to.emit(anchor, "ProvenAgainstAnchor")
          .withArgs(bn, trueRoot, account, slot, value);
        expectedSubmitCalls += 1n;
        expect(await verifier.submitCalls(), `[anchor ONCE] submitCalls != expected (c${c} s${s})`).to.equal(expectedSubmitCalls);
        expect(await verifier.recorded(ethers.keccak256(pv)), `[anchor RECORD] valid not recorded`).to.equal(true);
        successes++; steps++;
      }

      // End-of-campaign: the cumulative verify count equals exactly the successes.
      expect(await verifier.submitCalls(), `[anchor CUMULATIVE] verify count != successes (c${c})`).to.equal(expectedSubmitCalls);
    }

    console.log(`      [anchor] steps=${steps} success=${successes} notAnchored=${notAnchored} wrongRoot=${rootMismatch} crossBlock=${crossBlock} badLen=${badLen} invalidProof=${invalidProof}`);
    expect(steps).to.be.greaterThan(300);
    expect(successes).to.be.greaterThan(30);
    expect(rootMismatch + crossBlock).to.be.greaterThan(0);
  });
});
