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.
555 lines
28 KiB
JavaScript
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);
|
|
});
|
|
});
|