// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for
// AereRetroPGFRound1 (contracts/retropgf/AereRetroPGFRound1.sol) — the
// retroactive public-goods funding distributor (Merkle-rooted optimistic
// payout list, 14-day challenge window, 365-day sweep-to-reserve).
//
// LIVE-VERSION NOTE: sdk-js/src/addresses.ts contains NO RetroPGF entry (grep
// for "RetroPGF"/"Round1" returns nothing), so there is no canonical live
// address to pin. The contract under test is therefore the source-tree
// version at contracts/retropgf/AereRetroPGFRound1.sol, stood up fresh on the
// in-process hardhat network. Disclosed honestly.
//
// TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry — `forge`
// is not installed; the repo builds through hardhat with OZ 4.9.6 + viaIR).
// Every random choice comes from mulberry32(seed); the base seed is printed at
// suite start and pinnable via FUZZ_SEED. On any break, the failing
// (campaign, step, action, values) is embedded in the assertion message for a
// minimal repro.
//
// MOCKS (honestly disclosed): the funding token is the repo's MockERC20Lending
// (a plain mintable ERC20). The Merkle tree is built in JS with sorted-pair
// keccak256 hashing that matches OpenZeppelin MerkleProof.verify exactly (leaf
// = keccak256(abi.encode(recipient, uint8 category, uint256 amount))).
//
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
//     npx hardhat test test/retropgf-round1-invariant-property.test.js
// No deploy to any live node, no contract-logic change, one new test file only.
//
// INVARIANTS FUZZED (exactly the RetroPGF brief):
//   * DISTRIBUTION INTEGRITY: sum of all claimed AERE never exceeds the funded
//     TOTAL_POOL; every per-category claimed total stays <= its immutable cap;
//     the on-chain categoryClaimed[] mirrors the JS ledger of successful claims.
//   * NO OVER-DISTRIBUTION / CONSERVATION: contract token balance ==
//     TOTAL_POOL - sum(claimed) at all times pre-sweep (exactly TOTAL_POOL was
//     funded and nothing else ever transfers in); claims + final sweep move
//     out EXACTLY TOTAL_POOL, never more.
//   * SINGLE-CLAIM / OWN-ALLOCATION-ONLY: a leaf can be claimed at most once
//     (second attempt reverts AlreadyClaimed, balances unchanged); a claim for
//     an amount/category/recipient NOT in the tree reverts InvalidProof and
//     moves no funds; funds always route to the leaf's encoded recipient, never
//     to the (arbitrary) caller — so a relayer/griefer cannot redirect a payout.
//   * NO RECIPIENT OVER-PAY: each recipient's real token balance equals the sum
//     of its own successfully-claimed leaf amounts, and never exceeds what it
//     was allocated.
//   * BOUNDED ADMIN/SWEEP: sweep is blocked before earliestSweepTs; when it
//     fires it sends EXACTLY the residual balance to the immutable
//     ECOSYSTEM_RESERVE and nothing to anyone else; owner has no path to move
//     funds outside claim/sweep (non-owner propose/fund/dismiss revert).
//
// No em-dashes anywhere in this file.
// =============================================================================

const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");

/* ------------------------------- seeded PRNG ------------------------------ */
function mulberry32(a) {
  return function () {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0x2E70_9F01;
function rngFor(tag, campaign) {
  let h = BASE_SEED ^ (campaign * 0x9e3779b1);
  for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0;
  return mulberry32(h >>> 0);
}
const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive
const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)];
const chance = (rng, p) => rng() < p;

/* ------------------------------- misc helpers ----------------------------- */
const E = (n) => ethers.parseEther(String(n));
const CATEGORIES = 6;
const abiCoder = ethers.AbiCoder.defaultAbiCoder();

function randAddr(rng) {
  let hex = "0x";
  for (let i = 0; i < 40; i++) hex += "0123456789abcdef"[Math.floor(rng() * 16)];
  return ethers.getAddress(hex);
}

/* --------------------------- OZ-compatible Merkle ------------------------- */
// leaf = keccak256(abi.encode(address recipient, uint8 category, uint256 amount))
function leafHash(recipient, category, amount) {
  return ethers.keccak256(
    abiCoder.encode(["address", "uint8", "uint256"], [recipient, category, amount])
  );
}
// OZ _hashPair: commutative (sorted) keccak256 of the two 32-byte children.
function hashPair(a, b) {
  const [lo, hi] = BigInt(a) <= BigInt(b) ? [a, b] : [b, a];
  return ethers.keccak256(ethers.concat([lo, hi]));
}
// Standard binary Merkle with odd-node promotion; verifies under OZ MerkleProof
// because every internal hash is sorted-pair. Single-leaf tree => root == leaf.
function buildTree(leaves) {
  if (leaves.length === 0) return { root: ethers.ZeroHash, layers: [[]] };
  let layer = leaves.slice();
  const layers = [layer];
  while (layer.length > 1) {
    const next = [];
    for (let i = 0; i < layer.length; i += 2) {
      if (i + 1 < layer.length) next.push(hashPair(layer[i], layer[i + 1]));
      else next.push(layer[i]); // promote lone node
    }
    layers.push(next);
    layer = next;
  }
  return { root: layer[0], 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 = idx >> 1;
  }
  return proof;
}

/* -------------------------- iteration counts (env) ------------------------ */
const N = {
  CAMPAIGNS: Number(process.env.RPGF_CAMPAIGNS || 10),
  CLAIM_STEPS: Number(process.env.RPGF_CLAIM_STEPS || 60),
};

// =============================================================================
describe("INVARIANT: AereRetroPGFRound1 Merkle payout distribution", function () {
  this.timeout(0);

  let signers, tokenF, rpgfF, owner, reserve, callers;

  before(async function () {
    signers = await ethers.getSigners();
    owner = signers[0];
    reserve = signers[19]; // immutable ECOSYSTEM_RESERVE; kept out of the caller pool
    callers = signers.slice(1, 8); // arbitrary third-party claim submitters / challengers
    tokenF = await ethers.getContractFactory("MockERC20Lending");
    rpgfF = await ethers.getContractFactory("AereRetroPGFRound1");
    console.log(
      `      [rpgf] base seed 0x${BASE_SEED.toString(16)} | ` +
        `${N.CAMPAIGNS} campaigns x ${N.CLAIM_STEPS} claim-steps | reserve=${reserve.address.slice(0, 10)}`
    );
  });

  // Random caps that sum EXACTLY to totalPool (constructor requires it).
  function makeCaps(rng, totalPool) {
    const w = [];
    let wsum = 0n;
    for (let i = 0; i < CATEGORIES; i++) {
      const x = BigInt(ri(rng, 1, 100));
      w.push(x);
      wsum += x;
    }
    const caps = [];
    let acc = 0n;
    for (let i = 0; i < CATEGORIES - 1; i++) {
      const c = (totalPool * w[i]) / wsum;
      caps.push(c);
      acc += c;
    }
    caps.push(totalPool - acc); // last cap absorbs the rounding so the sum is exact
    // guarantee every cap is > 0 so we can always place at least one leaf
    for (let i = 0; i < CATEGORIES; i++) {
      if (caps[i] === 0n) {
        // steal 1 wei from the largest cap
        let mi = 0;
        for (let j = 1; j < CATEGORIES; j++) if (caps[j] > caps[mi]) mi = j;
        caps[mi] -= 1n;
        caps[i] += 1n;
      }
    }
    return caps;
  }

  // Build the allocation list. `overCat` (or -1) marks a category whose leaf
  // amounts intentionally exceed its cap so claim-time CategoryOverflow fires.
  function makeAllocations(rng, caps, overCat) {
    const allocs = []; // {recipient, category, amount}
    for (let cat = 0; cat < CATEGORIES; cat++) {
      const cap = caps[cat];
      const n = ri(rng, 1, 4);
      let placed = 0n;
      for (let k = 0; k < n; k++) {
        const headroom = cap > placed ? cap - placed : 0n;
        let amt;
        if (cat === overCat && k === n - 1) {
          // final leaf blows past the cap on purpose
          amt = (headroom > 0n ? headroom : 1n) + BigInt(ri(rng, 1, 1000));
        } else {
          if (headroom <= 1n) {
            amt = 1n; // still add a tiny leaf (may itself overflow -> handled at claim)
          } else {
            // leave room for later leaves in honest categories
            const frac = BigInt(ri(rng, 1, 60));
            amt = (headroom * frac) / 100n;
            if (amt === 0n) amt = 1n;
          }
        }
        allocs.push({ recipient: randAddr(rng), category: cat, amount: amt });
        placed += amt;
      }
    }
    return allocs;
  }

  async function deployRound(totalPool, caps) {
    const token = await tokenF.deploy("MockAERE", "mAERE", 18);
    await token.waitForDeployment();
    const rpgf = await rpgfF.connect(owner).deploy(
      await token.getAddress(),
      reserve.address,
      totalPool,
      caps
    );
    await rpgf.waitForDeployment();
    return { token, rpgf, addr: await rpgf.getAddress(), tokenAddr: await token.getAddress() };
  }

  // The core invariant battery. `led` is the JS ledger mirror.
  async function assertInvariants(ctx, rpgf, token, addr, caps, totalPool, led, swept) {
    // ---- per-category cap + on-chain vs JS ledger reconciliation ----
    let sumClaimed = 0n;
    for (let i = 0; i < CATEGORIES; i++) {
      const oc = await rpgf.categoryClaimed(i);
      expect(oc, `[rpgf LEDGER] categoryClaimed[${i}] on-chain=${oc} != JS=${led.claimedByCat[i]} ${ctx}`)
        .to.equal(led.claimedByCat[i]);
      expect(oc, `[rpgf CAP] categoryClaimed[${i}]=${oc} > cap=${caps[i]} ${ctx}`).to.be.lte(caps[i]);
      sumClaimed += oc;
    }

    // ---- DISTRIBUTION INTEGRITY: total claimed never exceeds the funded pool ----
    expect(sumClaimed, `[rpgf NO-OVER-DIST] sum(claimed)=${sumClaimed} > TOTAL_POOL=${totalPool} ${ctx}`)
      .to.be.lte(totalPool);

    // ---- CONSERVATION: exactly TOTAL_POOL was funded; nothing else transfers in.
    //      Pre-sweep the contract must hold TOTAL_POOL - sum(claimed); post-sweep 0. ----
    const bal = await token.balanceOf(addr);
    if (!swept) {
      expect(bal, `[rpgf CONSERVE] balance=${bal} != TOTAL_POOL-claimed=${totalPool - sumClaimed} ${ctx}`)
        .to.equal(totalPool - sumClaimed);
    } else {
      expect(bal, `[rpgf CONSERVE-SWEPT] balance=${bal} != 0 after sweep ${ctx}`).to.equal(0n);
    }

    // ---- NO RECIPIENT OVER-PAY: real balance == sum of own claimed leaves, and
    //      never exceeds the recipient's total allocation ----
    for (const r of Object.keys(led.claimedByRecipient)) {
      const rb = await token.balanceOf(r);
      expect(rb, `[rpgf RECIP-BAL] ${r} balance=${rb} != claimed=${led.claimedByRecipient[r]} ${ctx}`)
        .to.equal(led.claimedByRecipient[r]);
      expect(rb, `[rpgf RECIP-OVERPAY] ${r} got ${rb} > allocated ${led.allocByRecipient[r]} ${ctx}`)
        .to.be.lte(led.allocByRecipient[r]);
    }
  }

  // -------------------------------------------------------------------------
  it("distribution integrity, single-claim, conservation & bounded sweep across interleaved multi-actor sequences", async function () {
    let campaigns = 0, claimsOk = 0, doubleClaims = 0, badProofs = 0, overflows = 0,
        challenges = 0, dismissals = 0, earlyFinalizeReverts = 0, earlySweepReverts = 0,
        unauthReverts = 0, griefRoutes = 0, sweeps = 0, dupLeafSafe = 0;

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

      // -- randomized immutable config --
      const totalPool = E(ri(rng, 100, 10_000_000));
      const caps = makeCaps(rng, totalPool);
      const overCat = chance(rng, 0.45) ? ri(rng, 0, CATEGORIES - 1) : -1;

      const { token, rpgf, addr } = await deployRound(totalPool, caps);

      // JS ledger mirror
      const led = {
        claimedByCat: new Array(CATEGORIES).fill(0n),
        claimedByRecipient: {},
        allocByRecipient: {},
      };

      // -- allocation list + Merkle tree --
      const allocs = makeAllocations(rng, caps, overCat);
      // Occasionally inject an exact-duplicate tuple to probe the same-leaf
      // collapse (both map to one leafClaimed key -> at most one payout).
      let dupIdx = -1;
      if (chance(rng, 0.3) && allocs.length > 0) {
        const src = pick(rng, allocs);
        allocs.push({ recipient: src.recipient, category: src.category, amount: src.amount });
        dupIdx = allocs.length - 1;
      }
      for (const a of allocs) {
        led.allocByRecipient[a.recipient] =
          (led.allocByRecipient[a.recipient] || 0n) + a.amount;
      }
      const leaves = allocs.map((a) => leafHash(a.recipient, a.category, a.amount));
      const { root, layers } = buildTree(leaves);
      allocs.forEach((a, i) => {
        a.leaf = leaves[i];
        a.proof = getProof(layers, i);
        a.claimed = false;
      });

      // -- unauthorized checks before funding --
      await expect(rpgf.connect(callers[0]).fund()).to.be.reverted; // onlyOwner
      unauthReverts++;
      await expect(rpgf.connect(callers[0]).proposePayoutRoot(root)).to.be.reverted; // onlyOwner
      unauthReverts++;
      // propose before funding -> InsufficientFunding
      await expect(rpgf.connect(owner).proposePayoutRoot(root)).to.be.reverted;

      // -- fund exactly TOTAL_POOL --
      await (await token.mint(owner.address, totalPool)).wait();
      await (await token.connect(owner).approve(addr, totalPool)).wait();
      await (await rpgf.connect(owner).fund()).wait();
      await assertInvariants(`c${c} funded`, rpgf, token, addr, caps, totalPool, led, false);

      // -- propose root --
      await (await rpgf.connect(owner).proposePayoutRoot(root)).wait();

      // -- optional challenge / dismiss dance (only owner may dismiss) --
      if (chance(rng, 0.6)) {
        const ch = pick(rng, callers);
        await (await rpgf.connect(ch).challenge("suspected misallocation")).wait();
        challenges++;
        expect(await rpgf.state(), `[rpgf STATE] not Challenged after challenge c${c}`).to.equal(2n);
        // non-owner cannot dismiss
        await expect(rpgf.connect(callers[1]).dismissChallenge(0, "nope")).to.be.reverted;
        unauthReverts++;
        // owner may re-propose (allowed from Challenged) or dismiss to clear
        if (chance(rng, 0.5)) {
          await (await rpgf.connect(owner).proposePayoutRoot(root)).wait();
          // re-propose leaves the open challenge counted; dismiss to reach 0
          await (await rpgf.connect(owner).dismissChallenge(0, "reviewed, valid")).wait();
        } else {
          await (await rpgf.connect(owner).dismissChallenge(0, "reviewed, valid")).wait();
        }
        dismissals++;
        expect(await rpgf.state(), `[rpgf STATE] not Proposed after dismiss c${c}`).to.equal(1n);
      }

      // -- finalize BEFORE window elapses must revert --
      await expect(rpgf.connect(pick(rng, callers)).finalize()).to.be.reverted;
      earlyFinalizeReverts++;

      // advance a partial amount sometimes, then finish the window
      if (chance(rng, 0.5)) {
        await time.increase(ri(rng, 1, 13) * 24 * 60 * 60);
        await expect(rpgf.connect(owner).finalize()).to.be.reverted; // still inside 14d
        earlyFinalizeReverts++;
      }
      await time.increase(14 * 24 * 60 * 60 + 1);
      await (await rpgf.connect(pick(rng, callers)).finalize()).wait();
      expect(await rpgf.state(), `[rpgf STATE] not Finalized c${c}`).to.equal(3n);
      await assertInvariants(`c${c} finalized`, rpgf, token, addr, caps, totalPool, led, false);

      // sweep before the 365d window must revert
      await expect(rpgf.connect(pick(rng, callers)).sweepUnclaimed()).to.be.reverted;
      earlySweepReverts++;

      // -- interleaved claim phase --
      for (let s = 0; s < N.CLAIM_STEPS; s++) {
        const action = pick(rng, [
          "claim", "claim", "claim", "double", "badproof", "grief",
          "sweepEarly", "unauth", "advance",
        ]);
        const caller = pick(rng, callers); // ANY address may submit a claim
        const ctx = `c${c} s${s} action=${action} caller=${caller.address.slice(0, 8)}`;

        if (action === "claim") {
          const open = allocs.filter((a) => !a.claimed);
          if (open.length === 0) continue;
          const a = pick(rng, open);
          const wouldBe = led.claimedByCat[a.category] + a.amount;
          if (wouldBe > caps[a.category]) {
            // category cap enforced at claim time -> must revert, no movement
            await expect(
              rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof),
              `[rpgf OVERFLOW-EXPECTED] ${ctx} cat=${a.category} wouldBe=${wouldBe} cap=${caps[a.category]}`
            ).to.be.reverted;
            overflows++;
          } else {
            // duplicate-tuple leaf: if the twin already paid, this reverts AlreadyClaimed
            const twin = allocs.find(
              (x) => x !== a && x.claimed && x.leaf === a.leaf
            );
            if (twin) {
              await expect(
                rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof),
                `[rpgf DUP-LEAF] second identical tuple must revert AlreadyClaimed ${ctx}`
              ).to.be.reverted;
              a.claimed = true; // consumed for our bookkeeping (unclaimable henceforth)
              dupLeafSafe++;
            } else {
              await (await rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof)).wait();
              a.claimed = true;
              led.claimedByCat[a.category] += a.amount;
              led.claimedByRecipient[a.recipient] =
                (led.claimedByRecipient[a.recipient] || 0n) + a.amount;
              claimsOk++;
            }
          }
        } else if (action === "double") {
          const done = allocs.filter((a) => a.claimed && a.leaf);
          if (done.length === 0) continue;
          const a = pick(rng, done);
          const rbBefore = await token.balanceOf(a.recipient);
          await expect(
            rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof),
            `[rpgf DOUBLE] re-claim of consumed leaf must revert ${ctx}`
          ).to.be.reverted;
          expect(await token.balanceOf(a.recipient), `[rpgf DOUBLE-BAL] balance moved on failed re-claim ${ctx}`)
            .to.equal(rbBefore);
          doubleClaims++;
        } else if (action === "badproof") {
          // fabricate a leaf NOT in the tree: tweak amount, category, or recipient
          const a = pick(rng, allocs);
          const variant = ri(rng, 0, 2);
          let recip = a.recipient, cat = a.category, amt = a.amount;
          if (variant === 0) amt = a.amount + BigInt(ri(rng, 1, 1000));
          else if (variant === 1) cat = (a.category + 1) % CATEGORIES;
          else recip = randAddr(rng);
          const rbBefore = await token.balanceOf(recip);
          await expect(
            rpgf.connect(caller).claim(recip, cat, amt, a.proof),
            `[rpgf BADPROOF] forged leaf must revert InvalidProof/overflow ${ctx}`
          ).to.be.reverted;
          expect(await token.balanceOf(recip), `[rpgf BADPROOF-BAL] balance moved on forged claim ${ctx}`)
            .to.equal(rbBefore);
          badProofs++;
        } else if (action === "grief") {
          // A griefer submits a VALID unclaimed leaf. Funds must route to the
          // leaf's encoded recipient, never to the caller.
          const open = allocs.filter(
            (a) => !a.claimed && led.claimedByCat[a.category] + a.amount <= caps[a.category] &&
                   !allocs.find((x) => x !== a && x.claimed && x.leaf === a.leaf)
          );
          if (open.length === 0) continue;
          const a = pick(rng, open);
          const callerBefore = await token.balanceOf(caller.address);
          const recipBefore = await token.balanceOf(a.recipient);
          await (await rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof)).wait();
          a.claimed = true;
          led.claimedByCat[a.category] += a.amount;
          led.claimedByRecipient[a.recipient] =
            (led.claimedByRecipient[a.recipient] || 0n) + a.amount;
          // caller (griefer) gains nothing unless caller IS the recipient (random addrs => never)
          expect(await token.balanceOf(caller.address), `[rpgf GRIEF-CALLER] caller siphoned funds ${ctx}`)
            .to.equal(callerBefore);
          expect((await token.balanceOf(a.recipient)) - recipBefore,
            `[rpgf GRIEF-ROUTE] recipient not credited exactly amount ${ctx}`).to.equal(a.amount);
          griefRoutes++;
          claimsOk++;
        } else if (action === "sweepEarly") {
          await expect(
            rpgf.connect(caller).sweepUnclaimed(),
            `[rpgf SWEEP-EARLY] sweep before 365d must revert ${ctx}`
          ).to.be.reverted;
          earlySweepReverts++;
        } else if (action === "unauth") {
          // owner has no fund-exfil path once finalized
          await expect(rpgf.connect(owner).proposePayoutRoot(root)).to.be.reverted; // not in Funding/Challenged
          await expect(rpgf.connect(caller).dismissChallenge(0, "x")).to.be.reverted; // non-owner
          unauthReverts += 2;
        } else if (action === "advance") {
          await time.increase(ri(rng, 1, 30) * 24 * 60 * 60);
        }

        await assertInvariants(ctx, rpgf, token, addr, caps, totalPool, led, false);
      }

      // -- finale: cross the 365d sweep window and roll unclaimed to reserve --
      await time.increase(365 * 24 * 60 * 60 + 1);
      const reserveBefore = await token.balanceOf(reserve.address);
      const residual = await token.balanceOf(addr);
      let sumClaimedFinal = 0n;
      for (let i = 0; i < CATEGORIES; i++) sumClaimedFinal += led.claimedByCat[i];
      // residual must equal TOTAL_POOL - claimed (conservation) BEFORE the sweep
      expect(residual, `[rpgf RESIDUAL] c${c} residual=${residual} != pool-claimed=${totalPool - sumClaimedFinal}`)
        .to.equal(totalPool - sumClaimedFinal);

      await (await rpgf.connect(pick(rng, callers)).sweepUnclaimed()).wait();
      sweeps++;
      expect(await rpgf.state(), `[rpgf STATE] not Swept c${c}`).to.equal(4n);
      // reserve received EXACTLY the residual, nothing more
      expect((await token.balanceOf(reserve.address)) - reserveBefore,
        `[rpgf SWEEP-EXACT] reserve delta != residual c${c}`).to.equal(residual);
      // conservation closes: claims + sweep == TOTAL_POOL exactly
      expect(sumClaimedFinal + residual, `[rpgf CLOSE] claims+sweep != TOTAL_POOL c${c}`).to.equal(totalPool);
      await assertInvariants(`c${c} swept`, rpgf, token, addr, caps, totalPool, led, true);

      // sweeping again reverts (state == Swept); claims after sweep revert too
      await expect(rpgf.connect(owner).sweepUnclaimed()).to.be.reverted;
      const leftover = allocs.find((a) => !a.claimed);
      if (leftover) {
        await expect(
          rpgf.connect(owner).claim(leftover.recipient, leftover.category, leftover.amount, leftover.proof)
        ).to.be.reverted;
      }

      campaigns++;
    }

    console.log(
      `      [rpgf] campaigns=${campaigns} claimsOk=${claimsOk} grief-routed=${griefRoutes} ` +
        `doubleReverts=${doubleClaims} badProofReverts=${badProofs} capOverflowReverts=${overflows} ` +
        `dupLeafSafe=${dupLeafSafe} challenges=${challenges} dismissals=${dismissals} ` +
        `earlyFinalizeReverts=${earlyFinalizeReverts} earlySweepReverts=${earlySweepReverts} ` +
        `unauthReverts=${unauthReverts} sweeps=${sweeps}`
    );

    // coverage floors so a green run genuinely exercised the surface
    expect(campaigns).to.equal(N.CAMPAIGNS);
    expect(claimsOk, "expected many successful claims").to.be.greaterThan(30);
    expect(doubleClaims + badProofs, "expected double-claim & bad-proof rejections").to.be.greaterThan(20);
    expect(sweeps, "expected every campaign to sweep").to.equal(N.CAMPAIGNS);
  });
});
