aere-contracts/test/coinbase-splitter-v2-invariant-property.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

527 lines
24 KiB
JavaScript

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