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

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

555 lines
28 KiB
JavaScript

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