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