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.
527 lines
24 KiB
JavaScript
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
|
|
});
|
|
});
|