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