// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT test for AereCompliancePoolV2
// (CANONICAL compliant privacy pool, live 0xB144c923572E5Ac1B6B961C4ccfec36917173465).
//
// This is a defense-in-depth fuzz of the SECURITY-CRITICAL "no theft / no
// double-spend / conservation" invariants of the withdraw path. The membership
// / inclusion proof is mocked via MockPrivacyPoolVerifier (a real SP1/Groth16
// circuit is wired in production); toggling the mock lets us prove that a
// withdrawal is IMPOSSIBLE without a valid proof.
//
// INVARIANTS ENFORCED AFTER EVERY STEP (these are the point):
//   V1  PROOF-GATED (no theft without a proof): a withdraw with the verifier
//       returning false ALWAYS reverts InvalidProof and moves ZERO funds. A
//       withdraw only ever succeeds when accept==true AND every gate passes.
//   V2  NO-DOUBLE-SPEND: a nullifier can never be spent twice. A second
//       withdraw reusing a spent nullifier reverts AlreadySpent (checked before
//       the verifier, so even accept==true cannot double-withdraw). spent
//       nullifiers are monotone (once true, forever true).
//   V3  CONSERVATION: pool TOKEN balance == deposits*DENOM - withdrawnTotal
//       + bondsLocked, at all times. The deposit-backed portion
//       (balance - bondsLocked) == deposits*DENOM - withdrawnTotal >= 0.
//   V4  SINGLE-NOTE CAP: every completed withdraw removes EXACTLY one
//       DENOMINATION from the pool (toRecipient + fee + refund == DENOMINATION),
//       distributed to (recipient, feeRecipient, msg.sender). fee+refund can
//       never exceed a note (FeeTooLarge). Total notes withdrawn <= notes
//       deposited.
//   V5  DENOMINATION is immutable / fixed across the whole run.
//   V6  NO-ADMIN-DRAIN: no Foundation-only path reduces the deposit-backed
//       balance. After every Foundation action the deposit-backed ledger holds.
//   V7  GATES: withdraw requires a PUBLISHED association root (UnknownAssociationRoot
//       otherwise) and a KNOWN deposit root (UnknownDepositRoot otherwise).
//   V8  HONEST-BOND (V2's F8 fix): a DISMISSED challenge burns the bond to dEaD
//       and does NOT refund the challenger; an UPHELD challenge (window elapsed)
//       refunds the challenger EXACTLY the bond via reclaimUpheldChallenge (the
//       honest challenger is never robbed).
//
// TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (Foundry `forge`
// is not installed; the repo builds through hardhat + OZ 4.9.6 + viaIR). Every
// random choice comes from mulberry32(seed); the base seed is printed and can be
// pinned with FUZZ_SEED. On any break, the failing (campaign, step, action,
// actor, values) is embedded in the assertion message for a minimal repro.
//
// SCOPE: run this file IN ISOLATION (the full suite OOM/segfaults on unrelated
// ML-DSA PQC KAT tests):
//     npx hardhat test test/compliance-pool-v2-invariant-property.test.js
// TEST-ONLY: one new file, no contract logic changed, nothing deployed to any
// live node.
// =============================================================================

"use strict";

const { expect } = require("chai");
const { ethers } = require("hardhat");

/* ------------------------------ 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) : 0xC0119002;
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;

/* ------------------------------- constants -------------------------------- */
const DENOM = ethers.parseUnits("100", 18); // 1 note = 100 tokens
const BOND = ethers.parseEther("100"); // CHALLENGE_BOND
const DEAD = "0x000000000000000000000000000000000000dEaD";
const MAX_DISMISSALS = 2;
const WINDOW = 3600; // ASSOCIATION_ROOT_CHALLENGE_WINDOW (short, twin-style)

const N = {
  CAMPAIGNS: Number(process.env.CPV2_CAMPAIGNS || 6),
  STEPS: Number(process.env.CPV2_STEPS || 240),
};

async function nowTs() {
  return (await ethers.provider.getBlock("latest")).timestamp;
}
async function advance(dt) {
  await ethers.provider.send("evm_increaseTime", [dt]);
  await ethers.provider.send("evm_mine", []);
}

describe("INVARIANT: AereCompliancePoolV2 no-theft / no-double-spend / conservation", function () {
  this.timeout(0);

  let signers, foundation;
  let poolF, tokenF, verifierF;

  before(async function () {
    signers = await ethers.getSigners();
    if (signers.length < 20) throw new Error("need >= 20 hardhat accounts");
    foundation = signers[0];
    tokenF = await ethers.getContractFactory("MockERC20Lending");
    verifierF = await ethers.getContractFactory("MockPrivacyPoolVerifier");
    poolF = await ethers.getContractFactory("AereCompliancePoolV2");
    console.log(
      `      [cpv2] base seed 0x${BASE_SEED.toString(16)}, ${N.CAMPAIGNS} campaigns x ${N.STEPS} steps, window=${WINDOW}s`
    );
  });

  it("drives hundreds of randomized withdraw/deposit/challenge sequences; all security invariants hold", async function () {
    // Actor sets (disjoint so token-balance deltas stay exact).
    const depositors = signers.slice(1, 7); // 6
    const casps = [signers[7], signers[8]]; // 2 registered compliance providers
    const challengers = [signers[9], signers[10], signers[11]]; // 3
    const recipients = [signers[12], signers[13], signers[14], signers[15]]; // withdraw recipients / feeRecipients
    const callers = [signers[16], signers[17], signers[18], signers[19]]; // withdraw callers (get refund)

    // Aggregate counters across all campaigns (for final coverage asserts).
    let totalSteps = 0;
    let okWithdrawals = 0;
    let proofBlocked = 0;
    let doubleSpendBlocked = 0;
    let unpubBlocked = 0;
    let unknownRootBlocked = 0;
    let feeTooLargeBlocked = 0;
    let dismissals = 0;
    let reclaims = 0;
    let ledgerChecks = 0;

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

      // Fresh deploy per campaign.
      const token = await tokenF.deploy("WAERE", "WAERE", 18);
      await token.waitForDeployment();
      const tokenAddr = await token.getAddress();
      const verifier = await verifierF.deploy();
      await verifier.waitForDeployment();
      const pool = await poolF.deploy(tokenAddr, DENOM, foundation.address, await verifier.getAddress(), WINDOW);
      await pool.waitForDeployment();
      const poolAddr = await pool.getAddress();

      // Sanity on immutables.
      expect(await pool.DENOMINATION(), `[cpv2 c${c}] denom set`).to.equal(DENOM);
      const denomImmutable = await pool.DENOMINATION();

      // Fund + approve depositors and challengers with plenty of headroom.
      const fat = DENOM * 400n + BOND * 100n;
      for (const a of [...depositors, ...challengers]) {
        await token.mint(a.address, fat);
        await token.connect(a).approve(poolAddr, ethers.MaxUint256);
      }
      for (const cp of casps) {
        await pool.connect(foundation).setComplianceProvider(cp.address, true);
      }

      /* --------------------------- JS shadow model -------------------------- */
      let depositCount = 0n;
      let withdrawnTotal = 0n; // sum of DENOM per completed withdraw
      let bondsLocked = 0n; // BOND per active (open) challenge held in pool
      let bondsBurned = 0n; // BOND per dismissal, sent to dEaD
      let acceptState = true; // mirrors verifier.accept
      const depositRoots = []; // every newRoot from a deposit (all stay known; << 256)
      const spentNullifiers = new Set(); // nullifierHashes known-spent
      let usedSalt = 0;

      // association roots reserved for the WITHDRAW pipeline (propose -> wait -> publish)
      const pubRoots = []; // published, withdrawable
      // model of every proposed root for the publish pipeline
      const proposeModel = new Map(); // root -> { proposedAt }
      // model of challenge-lifecycle roots (separate, for bond testing)
      const chalModel = new Map(); // root -> { state, proposedAt, challenger, challengedAt, dismissCount }

      const randBytes32 = (tag) => ethers.keccak256(ethers.toUtf8Bytes(`${tag}:${c}:${usedSalt++}`));

      // Expected token balances of challengers (to prove bond no-theft precisely).
      const chalBal = {};
      for (const ch of challengers) chalBal[ch.address] = await token.balanceOf(ch.address);

      async function assertLedger(tag) {
        // V3 CONSERVATION
        const bal = await token.balanceOf(poolAddr);
        const expected = depositCount * DENOM - withdrawnTotal + bondsLocked;
        expect(bal, `[cpv2 V3 CONSERVATION] pool balance != deposits-withdrawn+bonds | c${c} after ${tag}`).to.equal(
          expected
        );
        const depositBacked = bal - bondsLocked;
        expect(depositBacked, `[cpv2 V3 NEG] deposit-backed balance underflow | c${c} after ${tag}`).to.equal(
          depositCount * DENOM - withdrawnTotal
        );
        expect(depositBacked >= 0n, `[cpv2 V3 NEG] deposit-backed < 0 | c${c} after ${tag}`).to.equal(true);
        // V4 single-note cap on the aggregate: notes out <= notes in
        expect(withdrawnTotal <= depositCount * DENOM, `[cpv2 V4 CAP] withdrawn>deposited | c${c} after ${tag}`).to.equal(
          true
        );
        // burned bonds live at dEaD, never in the pool
        expect(await token.balanceOf(DEAD), `[cpv2 V8 BURN] dEaD balance != bondsBurned | c${c} after ${tag}`).to.equal(
          bondsBurned
        );
        // V5 DENOMINATION fixed
        expect(await pool.DENOMINATION(), `[cpv2 V5 DENOM] denomination drifted | c${c} after ${tag}`).to.equal(
          denomImmutable
        );
        // V2 monotone spent-nullifiers (sample to bound cost)
        const sample = [...spentNullifiers];
        for (let i = 0; i < Math.min(sample.length, 6); i++) {
          const nh = sample[ri(rng, 0, sample.length - 1)];
          expect(await pool.spentNullifiers(nh), `[cpv2 V2 MONO] spent nullifier flipped false | c${c} after ${tag}`).to.equal(
            true
          );
        }
        ledgerChecks++;
      }

      // Pick a fresh withdraw triple with distinct addresses so deltas are exact.
      function withdrawParties() {
        const recipient = pick(rng, recipients);
        let feeRecipient = pick(rng, recipients);
        let guard = 0;
        while (feeRecipient.address === recipient.address && guard++ < 8) feeRecipient = pick(rng, recipients);
        let caller = pick(rng, callers);
        return { recipient, feeRecipient, caller };
      }

      await assertLedger("deploy+setup");

      for (let s = 0; s < N.STEPS; s++) {
        // Weighted actions. Early steps favor deposit + propose so withdrawals
        // have material to work with.
        const early = s < N.STEPS * 0.25;
        const menu = early
          ? ["deposit", "deposit", "deposit", "proposePub", "advance", "publish", "toggleVerifier"]
          : [
              "deposit",
              "withdrawValid",
              "withdrawValid",
              "withdrawNoProof",
              "withdrawDouble",
              "withdrawUnpub",
              "withdrawBadRoot",
              "withdrawFee",
              "proposePub",
              "publish",
              "advance",
              "toggleVerifier",
              "proposeChal",
              "challenge",
              "dismiss",
              "reclaim",
            ];
        const action = pick(rng, menu);

        try {
          if (action === "deposit") {
            const sender = pick(rng, depositors);
            const casp = pick(rng, casps);
            const commitment = randBytes32("commit");
            const leaf = ethers.keccak256(ethers.solidityPacked(["bytes32", "address"], [commitment, sender.address]));
            const tx = await pool.connect(sender).deposit(commitment, randBytes32("trh"), casp.address, "ipfs://tr");
            const rc = await tx.wait();
            const ev = rc.logs.map((l) => { try { return pool.interface.parseLog(l); } catch { return null; } }).find((p) => p && p.name === "Deposited");
            expect(ev, `[cpv2 c${c} s${s}] no Deposited event`).to.not.equal(undefined);
            expect(ev.args.leafIndex, `[cpv2 c${c} s${s}] leafIndex != depositCount`).to.equal(depositCount);
            depositCount += 1n;
            depositRoots.push(ev.args.newRoot);
          } else if (action === "toggleVerifier") {
            acceptState = chance(rng, 0.5);
            await (await verifier.setAccept(acceptState)).wait();
          } else if (action === "proposePub") {
            const root = randBytes32("assocpub");
            if (!proposeModel.has(root) && !pubRoots.includes(root)) {
              await (await pool.connect(foundation).proposeAssociationRoot(root)).wait();
              proposeModel.set(root, { proposedAt: await nowTs() });
            }
          } else if (action === "publish") {
            // find a proposed root whose window has elapsed (with margin vs the +1 auto-mine)
            const t = await nowTs();
            let target = null;
            for (const [root, m] of proposeModel) {
              if (t >= m.proposedAt + WINDOW) { target = root; break; }
            }
            if (target) {
              await (await pool.publishAssociationRoot(target)).wait();
              expect(await pool.isAssociationRootValid(target), `[cpv2 c${c} s${s}] publish did not mark valid`).to.equal(true);
              proposeModel.delete(target);
              pubRoots.push(target);
            }
          } else if (action === "advance") {
            await advance(ri(rng, WINDOW / 2, WINDOW * 2));
          } else if (action === "withdrawValid") {
            // Only when there is an unspent note (withdrawn notes < deposited notes)
            const notesOut = withdrawnTotal / DENOM;
            if (pubRoots.length === 0 || depositRoots.length === 0 || notesOut >= depositCount) continue;
            const assoc = pick(rng, pubRoots);
            const depRoot = pick(rng, depositRoots);
            const nh = randBytes32("nh");
            if (spentNullifiers.has(nh)) continue;
            const { recipient, feeRecipient, caller } = withdrawParties();
            const fee = chance(rng, 0.5) ? BigInt(ri(rng, 0, 25)) * ethers.parseEther("1") : 0n;
            const refund = chance(rng, 0.5) ? BigInt(ri(rng, 0, 25)) * ethers.parseEther("1") : 0n;
            if (fee + refund > DENOM) continue; // keep this path a genuine "valid" case
            const toRecipient = DENOM - fee - refund;

            if (!acceptState) {
              // V1: without a valid proof the withdraw MUST revert and move nothing.
              const balBefore = await token.balanceOf(poolAddr);
              await expect(
                pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, fee, refund),
                `[cpv2 V1 PROOF] no-proof withdraw did not revert | c${c} s${s}`
              ).to.be.revertedWithCustomError(pool, "InvalidProof");
              expect(await token.balanceOf(poolAddr), `[cpv2 V1 PROOF] balance moved on failed proof | c${c} s${s}`).to.equal(balBefore);
              expect(await pool.spentNullifiers(nh), `[cpv2 V1 PROOF] nullifier spent on failed proof | c${c} s${s}`).to.equal(false);
              proofBlocked++;
            } else {
              const rBefore = await token.balanceOf(recipient.address);
              const fBefore = await token.balanceOf(feeRecipient.address);
              const cBefore = await token.balanceOf(caller.address);
              const poolBefore = await token.balanceOf(poolAddr);
              // recipient / feeRecipient / caller might collide; capture per-address deltas by re-reading.
              await (await pool
                .connect(caller)
                .withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, fee, refund)).wait();
              const poolAfter = await token.balanceOf(poolAddr);
              // V4: exactly one note leaves the pool.
              expect(poolBefore - poolAfter, `[cpv2 V4 NOTE] pool delta != DENOM | c${c} s${s} fee=${fee} refund=${refund}`).to.equal(DENOM);
              // Distribution correctness (handle possible address collisions by expected-sum per address).
              const exp = {};
              const add = (addr, v) => { exp[addr] = (exp[addr] || 0n) + v; };
              add(recipient.address, toRecipient);
              add(feeRecipient.address, fee);
              add(caller.address, refund);
              const before = { [recipient.address]: rBefore, [feeRecipient.address]: fBefore, [caller.address]: cBefore };
              for (const addr of Object.keys(exp)) {
                const after = await token.balanceOf(addr);
                expect(after - before[addr], `[cpv2 V4 DIST] payout to ${addr} wrong | c${c} s${s} fee=${fee} refund=${refund}`).to.equal(exp[addr]);
              }
              spentNullifiers.add(nh);
              withdrawnTotal += DENOM;
              okWithdrawals++;
            }
          } else if (action === "withdrawDouble") {
            // V2: reuse a spent nullifier -> AlreadySpent (before verifier, even if accept==true)
            if (spentNullifiers.size === 0 || pubRoots.length === 0 || depositRoots.length === 0) continue;
            const nh = pick(rng, [...spentNullifiers]);
            const assoc = pick(rng, pubRoots);
            const depRoot = pick(rng, depositRoots);
            const { recipient, feeRecipient, caller } = withdrawParties();
            const balBefore = await token.balanceOf(poolAddr);
            await expect(
              pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, 0n, 0n),
              `[cpv2 V2 DOUBLE] double-spend did not revert | c${c} s${s} nh=${nh}`
            ).to.be.revertedWithCustomError(pool, "AlreadySpent");
            expect(await token.balanceOf(poolAddr), `[cpv2 V2 DOUBLE] balance moved on double-spend | c${c} s${s}`).to.equal(balBefore);
            doubleSpendBlocked++;
          } else if (action === "withdrawUnpub") {
            // V7: association root not published -> UnknownAssociationRoot
            if (depositRoots.length === 0) continue;
            const nh = randBytes32("nh");
            if (spentNullifiers.has(nh)) continue;
            // use a proposed-but-unpublished root if available, else a random unknown root
            const proposedOnly = [...proposeModel.keys()];
            const assoc = proposedOnly.length > 0 && chance(rng, 0.6) ? pick(rng, proposedOnly) : randBytes32("assocX");
            const depRoot = pick(rng, depositRoots);
            const { recipient, feeRecipient, caller } = withdrawParties();
            const balBefore = await token.balanceOf(poolAddr);
            await expect(
              pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, 0n, 0n),
              `[cpv2 V7 UNPUB] unpublished-assoc withdraw did not revert | c${c} s${s}`
            ).to.be.revertedWithCustomError(pool, "UnknownAssociationRoot");
            expect(await token.balanceOf(poolAddr), `[cpv2 V7 UNPUB] balance moved | c${c} s${s}`).to.equal(balBefore);
            expect(await pool.spentNullifiers(nh), `[cpv2 V7 UNPUB] nullifier spent | c${c} s${s}`).to.equal(false);
            unpubBlocked++;
          } else if (action === "withdrawBadRoot") {
            // V7: deposit root unknown -> UnknownDepositRoot (needs a PUBLISHED assoc so we pass gate 2)
            if (pubRoots.length === 0) continue;
            const nh = randBytes32("nh");
            if (spentNullifiers.has(nh)) continue;
            const assoc = pick(rng, pubRoots);
            const depRoot = randBytes32("fakeDepRoot"); // never inserted
            const { recipient, feeRecipient, caller } = withdrawParties();
            const balBefore = await token.balanceOf(poolAddr);
            await expect(
              pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, 0n, 0n),
              `[cpv2 V7 BADROOT] unknown-deposit-root withdraw did not revert | c${c} s${s}`
            ).to.be.revertedWithCustomError(pool, "UnknownDepositRoot");
            expect(await token.balanceOf(poolAddr), `[cpv2 V7 BADROOT] balance moved | c${c} s${s}`).to.equal(balBefore);
            expect(await pool.spentNullifiers(nh), `[cpv2 V7 BADROOT] nullifier spent | c${c} s${s}`).to.equal(false);
            unknownRootBlocked++;
          } else if (action === "withdrawFee") {
            // V4: fee + refund > DENOMINATION -> FeeTooLarge (can't exceed a single note)
            if (pubRoots.length === 0 || depositRoots.length === 0) continue;
            const nh = randBytes32("nh");
            if (spentNullifiers.has(nh)) continue;
            const assoc = pick(rng, pubRoots);
            const depRoot = pick(rng, depositRoots);
            const { recipient, feeRecipient, caller } = withdrawParties();
            // Construct fee+refund STRICTLY > DENOM: fee = DENOM - k, refund = k + j (j>=1)
            const k = BigInt(ri(rng, 0, 10)) * ethers.parseEther("1");
            const j = BigInt(ri(rng, 1, 50)) * ethers.parseEther("1");
            const fee = DENOM - k;
            const refund = k + j; // fee + refund = DENOM + j > DENOM
            const balBefore = await token.balanceOf(poolAddr);
            await expect(
              pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, fee, refund),
              `[cpv2 V4 FEE] over-note fee+refund did not revert | c${c} s${s} fee=${fee} refund=${refund}`
            ).to.be.revertedWithCustomError(pool, "FeeTooLarge");
            expect(await token.balanceOf(poolAddr), `[cpv2 V4 FEE] balance moved | c${c} s${s}`).to.equal(balBefore);
            feeTooLargeBlocked++;
          } else if (action === "withdrawNoProof") {
            // V1 (direct): with the verifier rejecting, a fully-valid-looking withdraw
            // MUST fail. Force accept=false for this probe, then restore.
            if (pubRoots.length === 0 || depositRoots.length === 0) continue;
            const prev = acceptState;
            if (acceptState) { await (await verifier.setAccept(false)).wait(); acceptState = false; }
            const nh = randBytes32("nh");
            if (spentNullifiers.has(nh)) { if (prev) { await (await verifier.setAccept(true)).wait(); acceptState = true; } continue; }
            const assoc = pick(rng, pubRoots);
            const depRoot = pick(rng, depositRoots);
            const { recipient, feeRecipient, caller } = withdrawParties();
            const balBefore = await token.balanceOf(poolAddr);
            await expect(
              pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, 0n, 0n),
              `[cpv2 V1 NOPROOF] rejected-proof withdraw did not revert | c${c} s${s}`
            ).to.be.revertedWithCustomError(pool, "InvalidProof");
            expect(await token.balanceOf(poolAddr), `[cpv2 V1 NOPROOF] balance moved | c${c} s${s}`).to.equal(balBefore);
            expect(await pool.spentNullifiers(nh), `[cpv2 V1 NOPROOF] nullifier spent | c${c} s${s}`).to.equal(false);
            proofBlocked++;
            if (prev) { await (await verifier.setAccept(true)).wait(); acceptState = true; }
          } else if (action === "proposeChal") {
            const root = randBytes32("assocchal");
            if (!chalModel.has(root)) {
              await (await pool.connect(foundation).proposeAssociationRoot(root)).wait();
              chalModel.set(root, { state: "proposed", proposedAt: await nowTs(), challenger: null, challengedAt: 0, dismissCount: 0 });
            }
          } else if (action === "challenge") {
            const candidates = [...chalModel.entries()].filter(([, m]) => m.state === "proposed");
            if (candidates.length === 0) continue;
            const [root, m] = pick(rng, candidates);
            const ch = pick(rng, challengers);
            if (m.dismissCount >= MAX_DISMISSALS) {
              await expect(
                pool.connect(ch).challengeAssociationRoot(root, "maxed"),
                `[cpv2 c${c} s${s}] maxed challenge did not revert`
              ).to.be.revertedWithCustomError(pool, "MaxDismissalsReached");
              continue;
            }
            const before = await token.balanceOf(ch.address);
            await (await pool.connect(ch).challengeAssociationRoot(root, "fuzz")).wait();
            expect(before - (await token.balanceOf(ch.address)), `[cpv2 V8 BONDIN] challenger did not pay exactly BOND | c${c} s${s}`).to.equal(BOND);
            m.state = "challenged";
            m.challenger = ch.address;
            m.challengedAt = await nowTs();
            bondsLocked += BOND;
          } else if (action === "dismiss") {
            // Dismiss must be WITHIN the window of the challenge. Only pick ones
            // safely inside (margin vs the +1 auto-mine).
            const t = await nowTs();
            const candidates = [...chalModel.entries()].filter(
              ([, m]) => m.state === "challenged" && t + 2 < m.challengedAt + WINDOW
            );
            if (candidates.length === 0) continue;
            const [root, m] = pick(rng, candidates);
            const challenger = m.challenger;
            const chBefore = await token.balanceOf(challenger);
            const deadBefore = await token.balanceOf(DEAD);
            await (await pool.connect(foundation).dismissAssociationRootChallenge(root)).wait();
            // V8: bond BURNED to dEaD, challenger NOT refunded.
            expect((await token.balanceOf(DEAD)) - deadBefore, `[cpv2 V8 DISMISS-BURN] dEaD did not gain BOND | c${c} s${s}`).to.equal(BOND);
            expect(await token.balanceOf(challenger), `[cpv2 V8 DISMISS-NOREFUND] challenger refunded on dismissal | c${c} s${s}`).to.equal(chBefore);
            bondsLocked -= BOND;
            bondsBurned += BOND;
            m.state = "proposed";
            m.dismissCount += 1;
            m.proposedAt = await nowTs();
            m.challenger = null;
            m.challengedAt = 0;
            dismissals++;
          } else if (action === "reclaim") {
            // Reclaim requires the dismissal window to have ELAPSED with the
            // challenge still standing.
            const t = await nowTs();
            const candidates = [...chalModel.entries()].filter(
              ([, m]) => m.state === "challenged" && t >= m.challengedAt + WINDOW
            );
            if (candidates.length === 0) continue;
            const [root, m] = pick(rng, candidates);
            const challenger = m.challenger;
            const chBefore = await token.balanceOf(challenger);
            // anyone can finalise; use a random caller
            await (await pool.connect(pick(rng, callers)).reclaimUpheldChallenge(root)).wait();
            // V8: honest challenger refunded EXACTLY the bond.
            expect((await token.balanceOf(challenger)) - chBefore, `[cpv2 V8 RECLAIM-REFUND] challenger not made whole | c${c} s${s}`).to.equal(BOND);
            bondsLocked -= BOND;
            m.state = "rejected";
            m.challenger = null;
            reclaims++;
          }
        } catch (e) {
          throw new Error(`[cpv2] action ${action} @ c${c} s${s}: ${e.message}`);
        }

        await assertLedger(`${action}#${s}`);
        totalSteps++;
      }

      // ---- Campaign finale: NO-ADMIN-DRAIN directed probe (V6) --------------
      // The Foundation is the only privileged actor. Prove that after exercising
      // EVERY Foundation-only entrypoint, the deposit-backed balance is untouched
      // and none of them transfers user deposits to the Foundation.
      {
        const backedBefore = (await token.balanceOf(poolAddr)) - bondsLocked;
        const foundationBalBefore = await token.balanceOf(foundation.address);
        // setComplianceProvider (toggle a fresh provider on then off)
        const probe = recipients[0].address;
        await (await pool.connect(foundation).setComplianceProvider(probe, true)).wait();
        await (await pool.connect(foundation).setComplianceProvider(probe, false)).wait();
        // proposeAssociationRoot (a fresh root) — moves no funds
        await (await pool.connect(foundation).proposeAssociationRoot(randBytes32("adminProbe"))).wait();
        const backedAfter = (await token.balanceOf(poolAddr)) - bondsLocked;
        expect(backedAfter, `[cpv2 V6 NO-DRAIN] Foundation action changed deposit-backed balance | c${c}`).to.equal(backedBefore);
        expect(await token.balanceOf(foundation.address), `[cpv2 V6 NO-DRAIN] Foundation received user funds | c${c}`).to.equal(
          foundationBalBefore
        );
        // No non-Foundation caller can invoke a Foundation-gated fn.
        await expect(
          pool.connect(pick(rng, depositors)).setComplianceProvider(probe, true),
          `[cpv2 V6 GATE] non-foundation setComplianceProvider not gated | c${c}`
        ).to.be.revertedWithCustomError(pool, "NotFoundation");
        await expect(
          pool.connect(pick(rng, depositors)).proposeAssociationRoot(randBytes32("hijack")),
          `[cpv2 V6 GATE] non-foundation propose not gated | c${c}`
        ).to.be.revertedWithCustomError(pool, "NotFoundation");
      }
      await assertLedger("finale");
    }

    console.log(
      `      [cpv2] steps=${totalSteps} okWithdrawals=${okWithdrawals} proofBlocked=${proofBlocked} ` +
        `doubleSpendBlocked=${doubleSpendBlocked} unpubBlocked=${unpubBlocked} unknownRootBlocked=${unknownRootBlocked} ` +
        `feeTooLarge=${feeTooLargeBlocked} dismissals=${dismissals} reclaims=${reclaims} ledgerChecks=${ledgerChecks}`
    );

    // Coverage floors — prove the security-critical paths were actually driven.
    expect(totalSteps, "steps floor").to.be.greaterThan(1000);
    expect(okWithdrawals, "successful withdrawals floor").to.be.greaterThan(30);
    expect(proofBlocked, "proof-gated blocks floor").to.be.greaterThan(20);
    expect(doubleSpendBlocked, "double-spend blocks floor").to.be.greaterThan(10);
    expect(unpubBlocked + unknownRootBlocked, "gate blocks floor").to.be.greaterThan(10);
    expect(feeTooLargeBlocked, "fee-too-large blocks floor").to.be.greaterThan(5);
    expect(dismissals + reclaims, "bond lifecycle floor").to.be.greaterThan(5);
  });
});
