// ============================================================================= // SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT tests for AereSink // (immutable 3-bucket revenue router: BURN / BUYBACK / STAKER-YIELD). // // CONTRACT UNDER TEST (live 0x69581B86A48161b067Ff4E01544780625B231676, 15/40/45) // contracts/sink/AereSink.sol — NO owner, NO admin, NO setter, NO pause. // flush(token, amount) pulls `amount`, splits by IMMUTABLE bps into three // buckets. On the AERE-native path the split is exact (the STAKER bucket eats // the integer-division residue, so no wei is created or lost). On a non-AERE // input each portion is swapped [token, AERE] via the DEX router. // // INVARIANTS FUZZED (exactly the ones that are the point): // C1 Conservation (AERE path): every wei in is routed; deltaBurnVault + // deltaStakerVault == amount, and the sink retains ZERO (fully dispatched). // C2 Split correctness: toBurnVault == floor(a*BURN)/1e4 + floor(a*BUYBACK)/1e4, // toStaker == a - that. Recomputed independently and matched exactly. // C3 Immutability of the split: BURN_BPS / BUYBACK_BPS / STAKER_YIELD_BPS and // every recipient (AERE/BURN_VAULT/SAERE_VAULT/DEX_ROUTER/ORACLE) are // read after every step and never change from their deploy values. // C4 No-admin surface: the ONLY state-mutating external functions are flush // and sweepDust; no owner / pause / setBps / setRecipient / withdraw / // rescue selector exists (so funds can never be redirected or drained). // C5 Cumulative proportion: over hundreds of mixed flushes+dust-sweeps the // cumulative per-bucket totals track the bps within bounded rounding // (<= 2 wei drift per operation), and global sum is conserved exactly. // C6 Non-AERE input conservation: whatever the swap outcome, the input token // is never created or lost — sinkBalance(token) + routerBalance(token) // always equals the total input ever flushed (failed swaps leave dust in // the sink; sweepDust re-routes it; successful swaps move exactly the // bps split to the router). // C7 Constructor guards: bps must sum to 10000 and no recipient may be zero. // // TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (Foundry `forge` // is not installed; the repo builds through hardhat + viaIR + a cancun override). // Every random choice comes from mulberry32(seed); the base seed is printed and // pinnable via FUZZ_SEED. On any break the campaign/step/actor/values are in the // assertion message for a minimal repro. // // SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the // unrelated ML-DSA PQC KAT tests). No deploy to any live node, no logic change, // exactly one new test file, reuses existing mocks (WAERE / MockSinkRouter / // MockPriceOracle) and plain EOAs for the bucket recipients. // // npx hardhat test test/aeresink-conservation-invariant.test.js // ============================================================================= const { expect } = require("chai"); const { ethers, network } = require("hardhat"); /* ------------------------------ 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) : 0x5117A2E0; 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; // Top up a signer's native balance (default signers hold only 10000 ETH, // which is too little to wrap the large WAERE deposits this fuzz needs). async function setBal(addr, weiBig) { await network.provider.send("hardhat_setBalance", [addr, "0x" + weiBig.toString(16)]); } const HEADROOM = 10n ** 27n; // 1e9 AERE of native headroom /* ------------------------------ misc helpers ------------------------------ */ const ONE = 10n ** 18n; // A random wei amount that spans tiny (rounding-edge) to large values. function randAmount(rng) { const bucket = ri(rng, 0, 3); if (bucket === 0) return BigInt(ri(rng, 1, 9)); // sub-10 wei rounding edge if (bucket === 1) return BigInt(ri(rng, 10, 100000)); // small if (bucket === 2) return BigInt(ri(rng, 1, 5000)) * (ONE / 1000n); // milli-AERE range return BigInt(ri(rng, 1, 250)) * ONE; // whole AERE range } // Random valid bps triple summing to exactly 10000 (allows zero buckets). function randBps(rng) { const burn = ri(rng, 0, 10000); const buyback = ri(rng, 0, 10000 - burn); const staker = 10000 - burn - buyback; return { burn, buyback, staker }; } // JS mirror of the contract split (AERE path == exact; non-AERE input split). function splitOf(amount, bps) { const burnPart = (amount * BigInt(bps.burn)) / 10000n; const buybackPart = (amount * BigInt(bps.buyback)) / 10000n; const stakerPart = amount - burnPart - buybackPart; return { burnPart, buybackPart, stakerPart, toBurnVault: burnPart + buybackPart, toStaker: stakerPart }; } const N = { AERE_CAMPAIGNS: Number(process.env.AERE_CAMPAIGNS || 12), AERE_STEPS: Number(process.env.AERE_STEPS || 60), SWAP_CAMPAIGNS: Number(process.env.SWAP_CAMPAIGNS || 8), SWAP_STEPS: Number(process.env.SWAP_STEPS || 40), }; const WAERE_FQN = "contracts/WAERE.sol:WAERE"; // Admin / redirect / drain selectors that MUST NOT exist on an immutable router. const FORBIDDEN_FNS = [ "owner", "transferOwnership", "renounceOwnership", "admin", "setAdmin", "pause", "unpause", "setPaused", "setBurnBps", "setBuybackBps", "setStakerBps", "setBps", "setSplit", "setBucket", "setRecipient", "setBurnVault", "setSaereVault", "setStaker", "setRouter", "setDexRouter", "setOracle", "withdraw", "withdrawTo", "rescue", "rescueTokens", "rescueERC20", "sweepTo", "emergencyWithdraw", "drain", "skim", ]; // ============================================================================= // 1. AERE-NATIVE PATH — exact conservation, split correctness, immutability // ============================================================================= describe("INVARIANT: AereSink AERE-path conservation + immutable split", function () { this.timeout(0); let signers, waereF, sinkF; before(async function () { signers = await ethers.getSigners(); waereF = await ethers.getContractFactory(WAERE_FQN); sinkF = await ethers.getContractFactory("AereSink"); console.log(` [aere] base seed 0x${BASE_SEED.toString(16)}, ${N.AERE_CAMPAIGNS} campaigns x ${N.AERE_STEPS} steps`); }); it("every wei in is routed to the immutable buckets; nothing created, lost, redirected, or drained", async function () { let totalOps = 0, flushOps = 0, sweepOps = 0, immutabilityChecks = 0, zeroBucketRounds = 0; // --- C4 once: no-admin surface (deploy-independent, but assert per contract) --- { const mutating = []; sinkF.interface.forEachFunction((f) => { if (f.stateMutability !== "view" && f.stateMutability !== "pure") mutating.push(f.name); }); mutating.sort(); expect(mutating, `[aere NO-ADMIN] mutating fn set drifted: ${mutating.join(",")}`) .to.deep.equal(["flush", "sweepDust"]); const names = new Set(); sinkF.interface.forEachFunction((f) => names.add(f.name)); for (const bad of FORBIDDEN_FNS) { expect(names.has(bad), `[aere NO-ADMIN] forbidden admin/drain fn present: ${bad}`).to.equal(false); } } for (let c = 0; c < N.AERE_CAMPAIGNS; c++) { const rng = rngFor("aere", c); const deployer = signers[0]; // Fresh AERE token per campaign so vault balances start at zero. const waere = await waereF.deploy(); await waere.waitForDeployment(); const aereAddr = await waere.getAddress(); // Bucket recipients are plain EOAs — a low-level sync() ping to an EOA is a // harmless no-op, and ERC20 transfers to them are trivially readable. const burnVault = signers[10 + (c % 5)]; const stakerVault = signers[15 + (c % 5)]; // Ensure the two vaults are distinct addresses. const stakerV = burnVault.address === stakerVault.address ? signers[16] : stakerVault; const routerEoa = signers[9]; // never called on the AERE path (non-zero required) const bps = randBps(rng); if (bps.burn === 0 || bps.buyback === 0 || bps.staker === 0) zeroBucketRounds++; const sink = await sinkF.deploy( aereAddr, burnVault.address, stakerV.address, routerEoa.address, bps.burn, bps.buyback, bps.staker, ri(rng, 0, 1000), // maxSlippageBps (unused on AERE path) ethers.ZeroAddress // oracle (unused on AERE path) ); await sink.waitForDeployment(); const sinkAddr = await sink.getAddress(); // Confirm the deploy pinned exactly the bps we asked for. expect(await sink.BURN_BPS()).to.equal(BigInt(bps.burn)); expect(await sink.BUYBACK_BPS()).to.equal(BigInt(bps.buyback)); expect(await sink.STAKER_YIELD_BPS()).to.equal(BigInt(bps.staker)); // Feeders (multi-actor): fund with AERE and approve the sink. const feeders = [signers[1], signers[2], signers[3], signers[4]]; for (const f of feeders) { await setBal(f.address, HEADROOM); await waere.connect(f).deposit({ value: 20000n * ONE }); await waere.connect(f).approve(sinkAddr, ethers.MaxUint256); } // Shadow accounting. let cumIn = 0n; // total AERE flowed in through flush + direct-dust let cumToBurn = 0n; // cumulative to BURN_VAULT let cumToStaker = 0n; // cumulative to STAKER vault let opsThisCampaign = 0; const burnBefore = await waere.balanceOf(burnVault.address); const stakerBefore = await waere.balanceOf(stakerV.address); async function assertImmutable(tag) { expect(await sink.AERE(), `[aere IMMUT AERE] ${tag}`).to.equal(aereAddr); expect(await sink.BURN_VAULT(), `[aere IMMUT BURN_VAULT] ${tag}`).to.equal(burnVault.address); expect(await sink.SAERE_VAULT(), `[aere IMMUT SAERE_VAULT] ${tag}`).to.equal(stakerV.address); expect(await sink.DEX_ROUTER(), `[aere IMMUT DEX_ROUTER] ${tag}`).to.equal(routerEoa.address); expect(await sink.BURN_BPS(), `[aere IMMUT BURN_BPS] ${tag}`).to.equal(BigInt(bps.burn)); expect(await sink.BUYBACK_BPS(), `[aere IMMUT BUYBACK_BPS] ${tag}`).to.equal(BigInt(bps.buyback)); expect(await sink.STAKER_YIELD_BPS(), `[aere IMMUT STAKER_BPS] ${tag}`).to.equal(BigInt(bps.staker)); immutabilityChecks++; } async function assertGlobalConservation(tag) { // C1/C5: sink fully dispatches on the AERE path — it never retains a wei. const sinkBal = await waere.balanceOf(sinkAddr); expect(sinkBal, `[aere RETAIN] sink holds AERE (not fully routed) c${c} ${tag} bal=${sinkBal}`).to.equal(0n); // Cumulative buckets exactly account for every wei that entered. const dBurn = (await waere.balanceOf(burnVault.address)) - burnBefore; const dStaker = (await waere.balanceOf(stakerV.address)) - stakerBefore; expect(dBurn, `[aere SHADOW-burn] on-chain != shadow c${c} ${tag}`).to.equal(cumToBurn); expect(dStaker, `[aere SHADOW-staker] on-chain != shadow c${c} ${tag}`).to.equal(cumToStaker); expect(dBurn + dStaker, `[aere CONSERVE] routed(${dBurn + dStaker}) != in(${cumIn}) c${c} ${tag}`) .to.equal(cumIn); // C5 proportion bound: burn-side never exceeds its bps share, and lags it // by at most 2 wei per op (residue always favors the staker bucket). const target = (cumIn * BigInt(bps.burn + bps.buyback)) / 10000n; expect(dBurn, `[aere PROP-hi] burn-side above bps target c${c} ${tag}`).to.be.lte(target); expect(target - dBurn, `[aere PROP-lo] burn-side drift > 2/op c${c} ${tag}`) .to.be.lte(2n * BigInt(opsThisCampaign)); } await assertImmutable("init"); await assertGlobalConservation("init"); for (let s = 0; s < N.AERE_STEPS; s++) { const action = pick(rng, ["flush", "flush", "flush", "sweepDirectDust", "advance"]); if (action === "advance") { await network.provider.send("evm_increaseTime", [ri(rng, 1, 3600)]); await network.provider.send("evm_mine", []); } else if (action === "flush") { const feeder = pick(rng, feeders); const bal = await waere.balanceOf(feeder.address); if (bal < 1n) continue; let amount = randAmount(rng); if (amount > bal) amount = bal; const exp = splitOf(amount, bps); const burnPre = await waere.balanceOf(burnVault.address); const stakerPre = await waere.balanceOf(stakerV.address); await (await sink.connect(feeder).flush(aereAddr, amount)).wait(); const burnDelta = (await waere.balanceOf(burnVault.address)) - burnPre; const stakerDelta = (await waere.balanceOf(stakerV.address)) - stakerPre; // C2: per-op split is EXACTLY the independently recomputed split. expect(burnDelta, `[aere SPLIT-burn] c${c} s${s} amt=${amount} bps=${bps.burn}/${bps.buyback}/${bps.staker}`) .to.equal(exp.toBurnVault); expect(stakerDelta, `[aere SPLIT-staker] c${c} s${s} amt=${amount}`).to.equal(exp.toStaker); // C1: the two bucket deltas sum to the whole input — no wei lost/created. expect(burnDelta + stakerDelta, `[aere SPLIT-sum] c${c} s${s} amt=${amount}`).to.equal(amount); cumIn += amount; cumToBurn += exp.toBurnVault; cumToStaker += exp.toStaker; flushOps++; opsThisCampaign++; } else if (action === "sweepDirectDust") { // Someone transfers AERE straight to the sink; sweepDust must route the // WHOLE balance out per the same immutable split (no wei stranded). const feeder = pick(rng, feeders); const bal = await waere.balanceOf(feeder.address); if (bal < 3n) continue; let dust = randAmount(rng); if (dust > bal) dust = bal; if (dust < 1n) continue; await (await waere.connect(feeder).transfer(sinkAddr, dust)).wait(); const sinkBal = await waere.balanceOf(sinkAddr); const exp = splitOf(sinkBal, bps); const burnPre = await waere.balanceOf(burnVault.address); const stakerPre = await waere.balanceOf(stakerV.address); await (await sink.connect(feeder).sweepDust(aereAddr)).wait(); const burnDelta = (await waere.balanceOf(burnVault.address)) - burnPre; const stakerDelta = (await waere.balanceOf(stakerV.address)) - stakerPre; expect(burnDelta, `[aere DUST-burn] c${c} s${s} bal=${sinkBal}`).to.equal(exp.toBurnVault); expect(stakerDelta, `[aere DUST-staker] c${c} s${s} bal=${sinkBal}`).to.equal(exp.toStaker); expect(burnDelta + stakerDelta, `[aere DUST-sum] c${c} s${s}`).to.equal(sinkBal); expect(await waere.balanceOf(sinkAddr), `[aere DUST-drain] sink not emptied c${c} s${s}`).to.equal(0n); cumIn += sinkBal; cumToBurn += exp.toBurnVault; cumToStaker += exp.toStaker; sweepOps++; opsThisCampaign++; } await assertImmutable(`c${c} s${s}`); await assertGlobalConservation(`c${c} s${s}`); totalOps++; } // sweepDust on an empty sink reverts ZeroAmount (no phantom emission). await expect(sink.connect(signers[1]).sweepDust(aereAddr)).to.be.revertedWithCustomError(sink, "ZeroAmount"); // flush(0) reverts ZeroAmount. await expect(sink.connect(signers[1]).flush(aereAddr, 0)).to.be.revertedWithCustomError(sink, "ZeroAmount"); } console.log(` [aere] ops=${totalOps} flush=${flushOps} sweep=${sweepOps} immutChecks=${immutabilityChecks} zeroBucketRounds=${zeroBucketRounds}`); expect(flushOps).to.be.greaterThan(100); expect(immutabilityChecks).to.be.greaterThan(200); }); }); // ============================================================================= // 2. NON-AERE INPUT PATH — input conservation across success + swap-failure dust // ============================================================================= describe("INVARIANT: AereSink non-AERE input conservation (swap + failure dust)", function () { this.timeout(0); let signers, waereF, sinkF, routerF; before(async function () { signers = await ethers.getSigners(); waereF = await ethers.getContractFactory(WAERE_FQN); sinkF = await ethers.getContractFactory("AereSink"); routerF = await ethers.getContractFactory("MockSinkRouter"); console.log(` [swap] base seed 0x${BASE_SEED.toString(16)}, ${N.SWAP_CAMPAIGNS} campaigns x ${N.SWAP_STEPS} steps`); }); it("input token is never created or lost: sink dust + router intake == total flushed, across funded/unfunded router", async function () { let totalOps = 0, successSwaps = 0, failedSwaps = 0, dustSweeps = 0; for (let c = 0; c < N.SWAP_CAMPAIGNS; c++) { const rng = rngFor("swap", c); const deployer = signers[0]; const aere = await waereF.deploy(); // the AERE output token await aere.waitForDeployment(); const aereAddr = await aere.getAddress(); const feeTok = await waereF.deploy(); // a non-AERE fee token as input await feeTok.waitForDeployment(); const feeAddr = await feeTok.getAddress(); const router = await routerF.deploy(); await router.waitForDeployment(); const routerAddr = await router.getAddress(); await (await router.setOutToken(aereAddr)).wait(); // router pays out AERE const burnVault = signers[11 + (c % 4)]; const stakerVault = signers[15 + (c % 4)]; const bps = randBps(rng); const sink = await sinkF.deploy( aereAddr, burnVault.address, stakerVault.address, routerAddr, bps.burn, bps.buyback, bps.staker, ri(rng, 0, 1000), ethers.ZeroAddress // oracle=0 -> amountOutMin=1 (trivial output; we test INPUT conservation) ); await sink.waitForDeployment(); const sinkAddr = await sink.getAddress(); // Feeders hold the fee token. await setBal(deployer.address, HEADROOM); const feeders = [signers[1], signers[2], signers[3]]; for (const f of feeders) { await setBal(f.address, HEADROOM); await feeTok.connect(f).deposit({ value: 20000n * ONE }); await feeTok.connect(f).approve(sinkAddr, ethers.MaxUint256); } let totalFeeIn = 0n; // total fee-token flushed in (via flush) let routerFunded = false; // whether router currently holds AERE to pay out async function fundRouter() { // Give the router ample AERE so both 1-wei payouts succeed. await aere.connect(deployer).deposit({ value: 10n * ONE }); await aere.connect(deployer).transfer(routerAddr, 10n * ONE); routerFunded = true; } async function assertInputConservation(tag) { // C6: the input token is conserved no matter the swap outcome. // Every fee-token wei ever flushed is either still dust in the sink or // was pulled into the router by a successful swap. Nothing else can hold // it (recipients only ever receive the AERE OUTPUT, never the input). const inSink = await feeTok.balanceOf(sinkAddr); const inRouter = await feeTok.balanceOf(routerAddr); expect(inSink + inRouter, `[swap CONSERVE] sink(${inSink})+router(${inRouter}) != in(${totalFeeIn}) c${c} ${tag}`) .to.equal(totalFeeIn); // Immutability spot-check. expect(await sink.BURN_BPS(), `[swap IMMUT burn] c${c} ${tag}`).to.equal(BigInt(bps.burn)); expect(await sink.BUYBACK_BPS(), `[swap IMMUT buyback] c${c} ${tag}`).to.equal(BigInt(bps.buyback)); expect(await sink.STAKER_YIELD_BPS(), `[swap IMMUT staker] c${c} ${tag}`).to.equal(BigInt(bps.staker)); expect(await sink.DEX_ROUTER(), `[swap IMMUT router] c${c} ${tag}`).to.equal(routerAddr); } await assertInputConservation("init"); // Directed probe: at campaign start the router holds ZERO AERE, so its // payout transfer reverts and AereSink's try/catch must leave the ENTIRE // input as dust in the sink (input conserved, approvals reset). This makes // the swap-failure path deterministic instead of RNG-dependent. { const feeder = feeders[0]; const amount = 1000n; expect(await aere.balanceOf(routerAddr)).to.equal(0n); const sinkPre = await feeTok.balanceOf(sinkAddr); await (await sink.connect(feeder).flush(feeAddr, amount)).wait(); totalFeeIn += amount; expect((await feeTok.balanceOf(sinkAddr)) - sinkPre, `[swap PROBE-dust] c${c}`).to.equal(amount); expect(await feeTok.balanceOf(routerAddr), `[swap PROBE-nointake] c${c}`).to.equal(0n); expect(await feeTok.allowance(sinkAddr, routerAddr), `[swap PROBE-approval] c${c}`).to.equal(0n); failedSwaps++; totalOps++; await assertInputConservation(`c${c} probe`); } for (let s = 0; s < N.SWAP_STEPS; s++) { const action = pick(rng, ["flushFunded", "flushUnfunded", "sweep", "advance"]); if (action === "advance") { await network.provider.send("evm_increaseTime", [ri(rng, 1, 600)]); await network.provider.send("evm_mine", []); await assertInputConservation(`c${c} s${s} adv`); totalOps++; continue; } if (action === "flushFunded" || action === "flushUnfunded") { const wantFunded = action === "flushFunded"; if (wantFunded && !routerFunded) await fundRouter(); // For "unfunded" we simply do NOT fund; if it was funded before, the // router may still hold AERE from earlier — that is fine, the input // conservation invariant holds regardless. We track intent only for // the success/failure counters below. const feeder = pick(rng, feeders); const bal = await feeTok.balanceOf(feeder.address); if (bal < 2n) continue; let amount = randAmount(rng); if (amount > bal) amount = bal; if (amount < 2n) amount = 2n; // ensure both swap legs can carry >=1 wei sometimes const exp = splitOf(amount, bps); const routerFeeBefore = await feeTok.balanceOf(routerAddr); const sinkFeeBefore = await feeTok.balanceOf(sinkAddr); await (await sink.connect(feeder).flush(feeAddr, amount)).wait(); totalFeeIn += amount; const routerPulled = (await feeTok.balanceOf(routerAddr)) - routerFeeBefore; const sinkGained = (await feeTok.balanceOf(sinkAddr)) - sinkFeeBefore; // Whatever happened, the amount split between router-intake and sink-dust // for THIS flush equals exactly the amount pulled from the feeder. expect(routerPulled + sinkGained, `[swap FLUSH-split] c${c} s${s} amt=${amount} pulled=${routerPulled} dust=${sinkGained}`) .to.equal(amount); const aereHasFunds = (await aere.balanceOf(routerAddr)) > 2n; if (aereHasFunds) { // Both swap legs should have succeeded: router pulled the entire input, // sink kept nothing new. (burn-side leg present only if toBurnVault>0.) expect(sinkGained, `[swap SUCCESS-nodust] c${c} s${s} amt=${amount}`).to.equal(0n); expect(routerPulled, `[swap SUCCESS-intake] c${c} s${s} amt=${amount}`).to.equal(exp.toBurnVault + exp.toStaker); successSwaps++; } else { // Unfunded router: payout transfer reverts -> both legs caught -> // the whole input stays as dust in the sink, approvals reset to 0. expect(routerPulled, `[swap FAIL-nointake] c${c} s${s} amt=${amount}`).to.equal(0n); expect(sinkGained, `[swap FAIL-dust] c${c} s${s} amt=${amount}`).to.equal(amount); // No stale approval left behind. expect(await feeTok.allowance(sinkAddr, routerAddr), `[swap FAIL-approval] c${c} s${s}`).to.equal(0n); failedSwaps++; } totalOps++; } else if (action === "sweep") { const inSink = await feeTok.balanceOf(sinkAddr); if (inSink === 0n) { await expect(sink.connect(signers[1]).sweepDust(feeAddr)).to.be.revertedWithCustomError(sink, "ZeroAmount"); } else { // Ensure the router can pay so the sweep actually clears the dust. if ((await aere.balanceOf(routerAddr)) <= 2n) await fundRouter(); const routerFeeBefore = await feeTok.balanceOf(routerAddr); await (await sink.connect(signers[1]).sweepDust(feeAddr)).wait(); const pulled = (await feeTok.balanceOf(routerAddr)) - routerFeeBefore; // The entire dust balance was re-routed out (input conserved, none lost). expect(pulled, `[swap SWEEP-intake] c${c} s${s} bal=${inSink}`).to.equal(inSink); expect(await feeTok.balanceOf(sinkAddr), `[swap SWEEP-empty] c${c} s${s}`).to.equal(0n); dustSweeps++; } totalOps++; } await assertInputConservation(`c${c} s${s}`); } } console.log(` [swap] ops=${totalOps} successSwaps=${successSwaps} failedSwaps=${failedSwaps} dustSweeps=${dustSweeps}`); expect(totalOps).to.be.greaterThan(100); expect(successSwaps).to.be.greaterThan(10); expect(failedSwaps).to.be.gte(N.SWAP_CAMPAIGNS); // >=1 guaranteed dust probe per campaign }); }); // ============================================================================= // 3. CONSTRUCTOR GUARDS — the split is fixed at deploy and cannot be malformed // ============================================================================= describe("INVARIANT: AereSink constructor guards (bps sum + non-zero recipients)", function () { this.timeout(0); let signers, sinkF; before(async function () { signers = await ethers.getSigners(); sinkF = await ethers.getContractFactory("AereSink"); }); it("rejects any bps triple that does not sum to 10000, and any zero recipient", async function () { const a = signers[1].address, b = signers[2].address, cc = signers[3].address, d = signers[4].address; const rng = mulberry32(BASE_SEED ^ 0x1234); // C7: fuzz many invalid bps triples (sum != 10000) -> BpsMustSumTo10000. let bpsChecks = 0; for (let i = 0; i < 60; i++) { let x, y, z; do { x = ri(rng, 0, 12000); y = ri(rng, 0, 12000); z = ri(rng, 0, 12000); } while (x + y + z === 10000 || x > 65535 || y > 65535 || z > 65535); await expect( sinkF.deploy(a, b, cc, d, x, y, z, 200, ethers.ZeroAddress), `[ctor BPS] sum ${x}+${y}+${z} should revert` ).to.be.revertedWithCustomError(sinkF, "BpsMustSumTo10000"); bpsChecks++; } // A valid triple with any zero recipient (aere/burn/saere/router) -> ZeroAddress. const Z = ethers.ZeroAddress; const valids = [[1500, 4000, 4500], [10000, 0, 0], [0, 0, 10000], [3333, 3333, 3334]]; for (const [x, y, z] of valids) { await expect(sinkF.deploy(Z, b, cc, d, x, y, z, 200, Z)).to.be.revertedWithCustomError(sinkF, "ZeroAddress"); await expect(sinkF.deploy(a, Z, cc, d, x, y, z, 200, Z)).to.be.revertedWithCustomError(sinkF, "ZeroAddress"); await expect(sinkF.deploy(a, b, Z, d, x, y, z, 200, Z)).to.be.revertedWithCustomError(sinkF, "ZeroAddress"); await expect(sinkF.deploy(a, b, cc, Z, x, y, z, 200, Z)).to.be.revertedWithCustomError(sinkF, "ZeroAddress"); } // And every valid triple deploys and pins exactly those immutable bps. for (const [x, y, z] of valids) { const s = await sinkF.deploy(a, b, cc, d, x, y, z, 200, ethers.ZeroAddress); await s.waitForDeployment(); expect(await s.BURN_BPS()).to.equal(BigInt(x)); expect(await s.BUYBACK_BPS()).to.equal(BigInt(y)); expect(await s.STAKER_YIELD_BPS()).to.equal(BigInt(z)); } console.log(` [ctor] invalid-bps rejections=${bpsChecks}, zero-address + valid-pin checks done`); expect(bpsChecks).to.equal(60); }); });