// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for AereCoinbaseSplitterV2
// (the 3-way validator-coinbase splitter: burn / AereSink / validator-rebate).
//
// Source under test: contracts/AereCoinbaseSplitterV2.sol. The canonical live
// address is sdk-js/src/addresses.ts -> AereCoinbaseSplitterV2
// (0x8C1A48eFA57b66fEE743A00E3899c29ad3Fd27b4). We stand up a byte-identical
// local instance; no live node is touched, no contract logic is changed, and
// nothing is deployed to any real chain. Exactly one new test file is added.
//
// TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (NOT 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 at
// suite start and pinnable via FUZZ_SEED. On any break, the failing
// (campaign, step, actor, action, values) is embedded in the assertion message
// so the repro is minimal.
//
// MOCKS (disclosed honestly):
//   * burn bucket  -> the REAL contracts/AereFeeBurnVault.sol (permanent sink,
//                     no withdraw; its AERE balance == cumulative burned).
//   * sink bucket  -> the REAL contracts/WAERE.sol wrapper + the existing
//                     contracts/mocks/MockSinkSimple.sol (an honest AereSink that
//                     pulls EXACTLY `amount` WAERE via transferFrom on flush()).
//   No behaviour is faked in a way that could hide a splitter bug: every wei is
//   traced to a real on-chain balance (burn vaults, the WAERE contract, the sink,
//   the validator EOAs) after every single step.
//
// SCOPE: run this file in ISOLATION (the full suite OOMs on the unrelated
// ML-DSA PQC KAT tests):
//     npx hardhat test test/coinbase-splitter-v2-invariant-property.test.js
//
// INVARIANTS FUZZED (exactly the CoinbaseSplitterV2 brief):
//   * CONSERVATION (per call): burnAmt + sinkAmt + rebateAmt == msg.value, to
//     the wei. No wei is minted; none is left stuck. The three lifetime counters
//     each advance by exactly their bucket amount.
//   * CONSERVATION (global): cumulative input == totalBurned + totalSentToSink +
//     totalRebated, always.
//   * FUNDS REALLY MOVE: totalBurned == sum(AERE held by every burn vault ever
//     used); totalSentToSink == WAERE.balanceOf(sink) == the WAERE contract's own
//     native balance; each rebate lands in the validator's balance to the wei
//     (gas-adjusted when validator == caller via splitToSelf).
//   * NO CUSTODY: the splitter's native AERE balance and its WAERE balance are
//     BOTH exactly 0 after every step (it fully dispatches; it has no receive/
//     fallback, so raw AERE cannot even be stranded on it).
//   * NO DRAIN / NO STEAL-PENDING: there is no owner path that withdraws value.
//     Mutating shares (setBps) or the burn vault (setBurnVault) mid-stream cannot
//     capture already-distributed funds, because the contract never holds a
//     pending balance (balance is 0 between calls); conservation still holds.
//   * ACCOUNTING MAPS: sum(burnedBy)==totalBurned, sum(sentToSinkBy)==
//     totalSentToSink, sum(rebatedTo)==totalRebated across interleaved callers.
//   * BPS SAFETY: burnBps<=5000, sinkBps<=3000, burnBps+sinkBps<=10000, and
//     rebateBps()==10000-burnBps-sinkBps hold under fuzzed setBps; out-of-range
//     setBps always reverts (BpsOutOfRange) and never mutates state.
//   * SINK TIMELOCK: acceptSink before the 7-day timelock always reverts and the
//     sink address is unchanged.
//
// 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) : 0xC0B5B172;
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 BPS = 10_000n;
const MAX_BURN_BPS = 5000n;
const MAX_SINK_BPS = 3000n;
const SEVEN_DAYS = 7 * 24 * 60 * 60;

async function setBal(addr, amountWei) {
  await network.provider.send("hardhat_setBalance", [addr, "0x" + amountWei.toString(16)]);
}
async function latestTs() {
  return BigInt((await ethers.provider.getBlock("latest")).timestamp);
}

// Random positive msg.value: mix wei-scale dust, small integers, and
// fractional-ether so the floor-division rounding in the split is stressed hard.
// Always > 0 (0 reverts with ZeroAmount, which is legal and not a value we drive).
function rndValue(rng) {
  const k = rng();
  if (k < 0.2) return BigInt(ri(rng, 1, 100_000)); // 1 wei .. 1e5 wei
  if (k < 0.5) return E(ri(rng, 1, 500));
  const num = E(ri(rng, 1, 90_000));
  return num / BigInt(ri(rng, 1, 997)); // fractional-ether, always > 0
}

// Iteration counts (env-overridable so a quick smoke can shrink them).
const N = {
  CAMPAIGNS: Number(process.env.CBS_CAMPAIGNS || 6),
  STEPS: Number(process.env.CBS_STEPS || 90),
};

// =============================================================================
describe("INVARIANT: AereCoinbaseSplitterV2 (3-way burn / sink / rebate)", function () {
  this.timeout(0);

  let signers, splitterF, burnVaultF, waereF, sinkF;

  before(async function () {
    signers = await ethers.getSigners();
    splitterF = await ethers.getContractFactory("AereCoinbaseSplitterV2");
    burnVaultF = await ethers.getContractFactory("AereFeeBurnVault");
    waereF = await ethers.getContractFactory("WAERE");
    sinkF = await ethers.getContractFactory("MockSinkSimple");
    console.log(
      `      [cbsv2] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} interleaved steps`
    );
  });

  // Fresh stack: WAERE + 2 real burn vaults (so setBurnVault swaps between them
  // and we can still prove the burned total lives in the vault set) + honest sink.
  async function deployStack(owner) {
    const waere = await waereF.connect(owner).deploy();
    await waere.waitForDeployment();
    const vaultA = await burnVaultF.connect(owner).deploy();
    await vaultA.waitForDeployment();
    const vaultB = await burnVaultF.connect(owner).deploy();
    await vaultB.waitForDeployment();
    const sink = await sinkF.connect(owner).deploy();
    await sink.waitForDeployment();

    const splitter = await splitterF
      .connect(owner)
      .deploy(await vaultA.getAddress(), await waere.getAddress());
    await splitter.waitForDeployment();

    return {
      waere,
      waereAddr: await waere.getAddress(),
      vaults: [vaultA, vaultB],
      vaultAddrs: [await vaultA.getAddress(), await vaultB.getAddress()],
      sink,
      sinkAddr: await sink.getAddress(),
      splitter,
      splitterAddr: await splitter.getAddress(),
    };
  }

  // Sum of a mapping(address=>uint) over a JS Set of keys.
  async function sumMapping(readFn, keys) {
    let s = 0n;
    for (const k of keys) s += await readFn(k);
    return s;
  }

  // The full invariant battery, asserted after EVERY step.
  async function assertInvariants(ctx, stk, state) {
    const { splitter, splitterAddr, waere, waereAddr, vaultAddrs, sinkAddr } = stk;

    const totalBurned = await splitter.totalBurned();
    const totalSink = await splitter.totalSentToSink();
    const totalRebated = await splitter.totalRebated();
    const burnBps = await splitter.burnBps();
    const sinkBps = await splitter.sinkBps();

    // ---- NO CUSTODY: splitter holds neither AERE nor WAERE, ever. ----
    expect(
      await ethers.provider.getBalance(splitterAddr),
      `[cbsv2 NO-CUSTODY-AERE] splitter retained native AERE ${ctx}`
    ).to.equal(0n);
    expect(
      await waere.balanceOf(splitterAddr),
      `[cbsv2 NO-CUSTODY-WAERE] splitter retained WAERE ${ctx}`
    ).to.equal(0n);

    // ---- GLOBAL CONSERVATION: every wei in == exactly the three buckets. ----
    expect(
      totalBurned + totalSink + totalRebated,
      `[cbsv2 CONSERVE] burned+sink+rebate != cumulative input ${ctx} ` +
        `in=${state.input} b=${totalBurned} s=${totalSink} r=${totalRebated}`
    ).to.equal(state.input);

    // ---- FUNDS REALLY MOVED: burned AERE sits in the burn-vault set. ----
    let vaultSum = 0n;
    for (const v of vaultAddrs) vaultSum += await ethers.provider.getBalance(v);
    expect(vaultSum, `[cbsv2 BURN-LANDED] vault AERE != totalBurned ${ctx}`).to.equal(totalBurned);

    // ---- sink AERE was wrapped into WAERE and pulled by the sink. ----
    expect(
      await waere.balanceOf(sinkAddr),
      `[cbsv2 SINK-LANDED] sink WAERE != totalSentToSink ${ctx}`
    ).to.equal(totalSink);
    // WAERE is fully backed: its native balance equals what the sink now holds,
    // which is precisely the AERE the splitter wrapped for the sink bucket.
    expect(
      await ethers.provider.getBalance(waereAddr),
      `[cbsv2 WAERE-BACKED] WAERE native balance != totalSentToSink ${ctx}`
    ).to.equal(totalSink);

    // ---- ACCOUNTING MAPS reconcile with the lifetime totals. ----
    expect(
      await sumMapping((k) => splitter.burnedBy(k), state.callers),
      `[cbsv2 MAP-BURN] sum(burnedBy) != totalBurned ${ctx}`
    ).to.equal(totalBurned);
    expect(
      await sumMapping((k) => splitter.sentToSinkBy(k), state.callers),
      `[cbsv2 MAP-SINK] sum(sentToSinkBy) != totalSentToSink ${ctx}`
    ).to.equal(totalSink);
    expect(
      await sumMapping((k) => splitter.rebatedTo(k), state.validators),
      `[cbsv2 MAP-REBATE] sum(rebatedTo) != totalRebated ${ctx}`
    ).to.equal(totalRebated);

    // ---- BPS SAFETY: caps hold and rebateBps identity is exact. ----
    expect(burnBps, `[cbsv2 BPS-BURN-CAP] burnBps>MAX ${ctx}`).to.be.lte(MAX_BURN_BPS);
    expect(sinkBps, `[cbsv2 BPS-SINK-CAP] sinkBps>MAX ${ctx}`).to.be.lte(MAX_SINK_BPS);
    expect(burnBps + sinkBps, `[cbsv2 BPS-SUM] burnBps+sinkBps>BPS ${ctx}`).to.be.lte(BPS);
    expect(
      await splitter.rebateBps(),
      `[cbsv2 BPS-REBATE-ID] rebateBps != BPS-burn-sink ${ctx}`
    ).to.equal(BPS - burnBps - sinkBps);
  }

  // Execute one split (either variant) and prove the per-call split is EXACT:
  // returns the deltas so the caller can fold them into cumulative accounting.
  async function doSplit(ctx, stk, state, caller, validator, value, self) {
    const { splitter, waere, waereAddr, vaultAddrs, sinkAddr, splitterAddr } = stk;

    // current split parameters
    const burnBps = await splitter.burnBps();
    const sinkBps = await splitter.sinkBps();
    const sinkSet = (await splitter.sink()) !== ethers.ZeroAddress;

    // expected buckets (mirror of the contract's arithmetic)
    const burnAmt = (value * burnBps) / BPS;
    const sinkAmt = sinkSet ? (value * sinkBps) / BPS : 0n;
    const rebateAmt = value - burnAmt - sinkAmt;

    // per-call conservation of the EXPECTED buckets (sanity on our own mirror)
    expect(burnAmt + sinkAmt + rebateAmt, `[cbsv2 MIRROR] expected buckets != value ${ctx}`).to.equal(
      value
    );

    // snapshots
    const b0 = await splitter.totalBurned();
    const s0 = await splitter.totalSentToSink();
    const r0 = await splitter.totalRebated();
    let vault0 = 0n;
    for (const v of vaultAddrs) vault0 += await ethers.provider.getBalance(v);
    const sink0 = await waere.balanceOf(sinkAddr);
    const valBal0 = await ethers.provider.getBalance(validator.address);
    const activeVaultBal0 = await ethers.provider.getBalance(await splitter.burnVault());

    let rc;
    if (self) {
      const tx = await splitter.connect(caller).splitToSelf({ value });
      rc = await tx.wait();
    } else {
      const tx = await splitter.connect(caller).splitAndDistribute(validator.address, { value });
      rc = await tx.wait();
    }
    const gasCost = rc.gasUsed * rc.gasPrice;

    // ---- lifetime counters advanced by EXACTLY the bucket amounts ----
    expect((await splitter.totalBurned()) - b0, `[cbsv2 DELTA-BURN] ${ctx}`).to.equal(burnAmt);
    expect((await splitter.totalSentToSink()) - s0, `[cbsv2 DELTA-SINK] ${ctx}`).to.equal(sinkAmt);
    expect((await splitter.totalRebated()) - r0, `[cbsv2 DELTA-REBATE] ${ctx}`).to.equal(rebateAmt);

    // ---- burned AERE actually landed in the ACTIVE burn vault ----
    const activeVault = await splitter.burnVault();
    expect(
      (await ethers.provider.getBalance(activeVault)) - activeVaultBal0,
      `[cbsv2 BURN-TO-VAULT] active vault delta != burnAmt ${ctx}`
    ).to.equal(burnAmt);

    // ---- sink WAERE increased by exactly sinkAmt ----
    expect(
      (await waere.balanceOf(sinkAddr)) - sink0,
      `[cbsv2 SINK-DELTA] ${ctx}`
    ).to.equal(sinkAmt);

    // ---- rebate reached the validator to the wei (gas-adjusted for splitToSelf) ----
    const valBal1 = await ethers.provider.getBalance(validator.address);
    // For splitToSelf the caller IS the validator: it also SENT `value` and paid
    // gas, so its net balance delta is rebateAmt - value - gasCost. For the
    // distinct-validator case the validator only receives, so it is exactly rebateAmt.
    const expectedValDelta = self ? rebateAmt - value - gasCost : rebateAmt;
    expect(valBal1 - valBal0, `[cbsv2 REBATE-RECEIVED] validator delta != rebate ${ctx}`).to.equal(
      expectedValDelta
    );

    // ---- splitter is empty right after the call ----
    expect(
      await ethers.provider.getBalance(splitterAddr),
      `[cbsv2 POST-EMPTY-AERE] ${ctx}`
    ).to.equal(0n);
    expect(await waere.balanceOf(splitterAddr), `[cbsv2 POST-EMPTY-WAERE] ${ctx}`).to.equal(0n);

    // fold into cumulative
    state.input += value;
    state.callers.add(caller.address);
    state.validators.add(validator.address);
    return { burnAmt, sinkAmt, rebateAmt };
  }

  // -------------------------------------------------------------------------
  it("interleaved multi-actor splits + bps/vault/sink churn: conservation, no custody, no drain", async function () {
    let steps = 0,
      splitsDist = 0,
      splitsSelf = 0,
      bpsChanges = 0,
      bpsReverts = 0,
      vaultSwaps = 0,
      sinkProposals = 0,
      sinkAccepts = 0,
      sinkCancels = 0,
      earlyAcceptReverts = 0;

    for (let c = 0; c < N.CAMPAIGNS; c++) {
      const rng = rngFor("gen", c);
      const owner = signers[0];
      const stk = await deployStack(owner);

      // callers PAY gas and are credited burn/sink; validators only RECEIVE the
      // rebate. Keep the two pools disjoint so validator balances are clean,
      // except splitToSelf (which we gas-adjust per call).
      const callers = [signers[1], signers[2], signers[3], signers[4]];
      const validators = [signers[11], signers[12], signers[13], signers[14]];
      for (const a of [...callers]) await setBal(a.address, E("100000000000"));

      const state = { input: 0n, callers: new Set(), validators: new Set() };
      await assertInvariants(`c${c} init`, stk, state);

      // Structural NO-DRAIN check: the splitter has no receive/fallback, so a
      // raw AERE send cannot even strand value on it (which is the only thing an
      // owner could later try to skim). It must revert; balance stays 0.
      await expect(
        callers[0].sendTransaction({ to: stk.splitterAddr, value: E(1) }),
        `[cbsv2 NO-RAW-RECV] c${c}`
      ).to.be.reverted;
      await assertInvariants(`c${c} post-raw-send`, stk, state);

      for (let s = 0; s < N.STEPS; s++) {
        const action = pick(rng, [
          "split", "split", "split", "split",
          "selfsplit", "selfsplit",
          "setBps", "setBps",
          "setBpsBad",
          "swapVault",
          "proposeSink",
          "acceptSink",
          "acceptSinkEarly",
          "cancelSink",
          "advance",
        ]);
        const ctx = `c${c} s${s} action=${action}`;

        try {
          if (action === "split") {
            const caller = pick(rng, callers);
            const validator = pick(rng, validators);
            await doSplit(ctx, stk, state, caller, validator, rndValue(rng), false);
            splitsDist++;
          } else if (action === "selfsplit") {
            const caller = pick(rng, callers);
            await doSplit(ctx, stk, state, caller, caller, rndValue(rng), true);
            splitsSelf++;
          } else if (action === "setBps") {
            // in-range: guaranteed to satisfy all three checks
            const nb = BigInt(ri(rng, 0, Number(MAX_BURN_BPS)));
            const maxSink = BPS - nb < MAX_SINK_BPS ? BPS - nb : MAX_SINK_BPS;
            const ns = BigInt(ri(rng, 0, Number(maxSink)));
            await (await stk.splitter.connect(owner).setBps(nb, ns)).wait();
            expect(await stk.splitter.burnBps(), `[cbsv2 SETBPS-B] ${ctx}`).to.equal(nb);
            expect(await stk.splitter.sinkBps(), `[cbsv2 SETBPS-S] ${ctx}`).to.equal(ns);
            bpsChanges++;
          } else if (action === "setBpsBad") {
            // out-of-range: MUST revert and leave state untouched
            const b0 = await stk.splitter.burnBps();
            const s0 = await stk.splitter.sinkBps();
            const kind = ri(rng, 0, 2);
            let nb, ns;
            if (kind === 0) {
              nb = MAX_BURN_BPS + BigInt(ri(rng, 1, 5000));
              ns = 0n;
            } else if (kind === 1) {
              nb = 0n;
              ns = MAX_SINK_BPS + BigInt(ri(rng, 1, 5000));
            } else {
              nb = MAX_BURN_BPS; // 5000
              ns = MAX_SINK_BPS; // 3000 -> caps ok individually, sum 8000 <=BPS so pick bigger
              // force sum>BPS while staying under caps is impossible (5000+3000=8000).
              // Instead push burn over cap to guarantee revert via the sum path too.
              nb = MAX_BURN_BPS + 1n;
              ns = MAX_SINK_BPS;
            }
            await expect(
              stk.splitter.connect(owner).setBps(nb, ns),
              `[cbsv2 SETBPS-BAD-REVERT] ${ctx} nb=${nb} ns=${ns}`
            ).to.be.revertedWithCustomError(stk.splitter, "BpsOutOfRange");
            expect(await stk.splitter.burnBps(), `[cbsv2 SETBPS-BAD-B] ${ctx}`).to.equal(b0);
            expect(await stk.splitter.sinkBps(), `[cbsv2 SETBPS-BAD-S] ${ctx}`).to.equal(s0);
            bpsReverts++;
          } else if (action === "swapVault") {
            // redirect the burn bucket to the OTHER real burn vault. This is a
            // config change, not a drain: it only affects FUTURE burns, cannot
            // touch already-distributed funds (splitter balance is 0), and the
            // burn-vault SET still sums to totalBurned.
            const cur = await stk.splitter.burnVault();
            const other =
              cur.toLowerCase() === stk.vaultAddrs[0].toLowerCase()
                ? stk.vaultAddrs[1]
                : stk.vaultAddrs[0];
            await (await stk.splitter.connect(owner).setBurnVault(other)).wait();
            expect((await stk.splitter.burnVault()).toLowerCase(), `[cbsv2 SWAP] ${ctx}`).to.equal(
              other.toLowerCase()
            );
            vaultSwaps++;
          } else if (action === "proposeSink") {
            await (await stk.splitter.connect(owner).proposeSink(stk.sinkAddr)).wait();
            sinkProposals++;
          } else if (action === "acceptSink") {
            const pending = await stk.splitter.pendingSink();
            if (pending === ethers.ZeroAddress) {
              // nothing pending -> would revert NoPendingProposal; skip cleanly
            } else {
              const earliest = await stk.splitter.pendingSinkEarliestSetTs();
              const now = await latestTs();
              if (now >= earliest) {
                await (await stk.splitter.connect(owner).acceptSink()).wait();
                expect(await stk.splitter.sink(), `[cbsv2 ACCEPT] ${ctx}`).to.equal(stk.sinkAddr);
                sinkAccepts++;
              }
            }
          } else if (action === "acceptSinkEarly") {
            // if a proposal exists and the timelock is NOT elapsed, accept MUST revert
            const pending = await stk.splitter.pendingSink();
            if (pending !== ethers.ZeroAddress) {
              const earliest = await stk.splitter.pendingSinkEarliestSetTs();
              const now = await latestTs();
              if (now < earliest) {
                const sinkBefore = await stk.splitter.sink();
                await expect(
                  stk.splitter.connect(owner).acceptSink(),
                  `[cbsv2 EARLY-ACCEPT-REVERT] ${ctx}`
                ).to.be.revertedWithCustomError(stk.splitter, "TimelockNotElapsed");
                expect(await stk.splitter.sink(), `[cbsv2 EARLY-ACCEPT-NOCHANGE] ${ctx}`).to.equal(
                  sinkBefore
                );
                earlyAcceptReverts++;
              }
            }
          } else if (action === "cancelSink") {
            const pending = await stk.splitter.pendingSink();
            if (pending !== ethers.ZeroAddress) {
              await (await stk.splitter.connect(owner).cancelPendingSink()).wait();
              expect(await stk.splitter.pendingSink(), `[cbsv2 CANCEL] ${ctx}`).to.equal(
                ethers.ZeroAddress
              );
              sinkCancels++;
            }
          } else if (action === "advance") {
            // push time forward so a pending sink proposal can mature past its
            // 7-day timelock (and generally exercise the clock).
            await time.increase(ri(rng, 1, SEVEN_DAYS + 3600));
            await network.provider.send("evm_mine");
          }
        } catch (e) {
          // Only legitimate reverts (e.g. ZeroAmount on a rounding edge, or an
          // ownable guard) are tolerated; any invariant break is re-thrown.
          if (/cbsv2 [A-Z]|INVARIANT/.test(String(e.message))) throw e;
        }

        await assertInvariants(ctx, stk, state);
        steps++;
      }

      // ---- finale: force the sink live (mature the timelock), then a final
      //      burst of splits so the 3-bucket path (sink != 0) is definitely
      //      exercised, and re-assert everything. ----
      const pend = await stk.splitter.pendingSink();
      if (pend === ethers.ZeroAddress && (await stk.splitter.sink()) === ethers.ZeroAddress) {
        await (await stk.splitter.connect(owner).proposeSink(stk.sinkAddr)).wait();
      }
      await time.increase(SEVEN_DAYS + 10);
      await network.provider.send("evm_mine");
      if ((await stk.splitter.sink()) === ethers.ZeroAddress && (await stk.splitter.pendingSink()) !== ethers.ZeroAddress) {
        await (await stk.splitter.connect(owner).acceptSink()).wait();
        sinkAccepts++;
      }
      // ensure non-zero bps in both burn and sink so all three buckets are live
      await (await stk.splitter.connect(owner).setBps(3750n, 1500n)).wait();
      for (let k = 0; k < 6; k++) {
        const caller = pick(rng, callers);
        const validator = pick(rng, validators);
        await doSplit(`c${c} finale${k}`, stk, state, caller, validator, rndValue(rng), false);
        splitsDist++;
      }
      await assertInvariants(`c${c} finale`, stk, state);
    }

    console.log(
      `      [cbsv2 gen] steps=${steps} split=${splitsDist} selfsplit=${splitsSelf} ` +
        `bpsChg=${bpsChanges} bpsRevert=${bpsReverts} vaultSwap=${vaultSwaps} ` +
        `sinkProp=${sinkProposals} sinkAccept=${sinkAccepts} sinkCancel=${sinkCancels} earlyAcceptRevert=${earlyAcceptReverts}`
    );
    // coverage floors so a green run is meaningful, not vacuous
    expect(steps).to.be.greaterThan(400);
    expect(splitsDist + splitsSelf).to.be.greaterThan(120);
    expect(bpsChanges).to.be.greaterThan(20);
    expect(bpsReverts).to.be.greaterThan(10);
    expect(sinkAccepts).to.be.greaterThan(0); // the 3-bucket (sink live) path was exercised
  });
});
