aere-contracts/test/insurance-fund-invariant-property.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

601 lines
30 KiB
JavaScript

// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
// AereInsuranceFund (canonical mainnet 0x5Ab95C549c2A2b07913Df7edD4a16fd108B7CAAC
// per sdk-js/src/addresses.ts, "7-day cooldown"). Source under test:
// contracts/lending/AereInsuranceFund.sol
// exercised against the REAL AereLendingMarket (contracts/lending/
// AereLendingMarket.sol), which is the production counterpart whose
// repayOnBehalfOf / debtBalance / DEBT_TOKEN the fund actually calls. Genuine
// BadDebt is manufactured via an insolvent liquidation, exactly the situation
// the fund exists to absorb. Market interest is frozen (setBorrowRate(0)) so
// debt accounting is exact and every invariant is crisp.
//
// TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry -- `forge`
// is not installed and the repo builds through hardhat with OZ 4.9.6 + viaIR).
// Every random choice comes from mulberry32(seed); the base seed is printed at
// suite start and pinnable via FUZZ_SEED. On any break the failing
// (campaign, step, actor, action, values) is embedded in the assertion message
// for a minimal repro. Existing repo mocks are reused (MockERC20Lending,
// MockAggregator); no new contract is added, nothing is deployed to any node.
//
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
// npx hardhat test test/insurance-fund-invariant-property.test.js
//
// INVARIANTS FUZZED (exactly the AereInsuranceFund brief):
// G1 CUSTODY-BALANCE: for every token, real ERC-20 balanceOf(fund) equals the
// fund's tracked balances[token] plus any untracked stray transfers. The
// tracked mapping is NEVER greater than the real balance, so the fund can
// never be asked to pay out more than it truly holds.
// G2 ACCOUNTING-CONSERVATION: balances[token] == totalDonated - totalCovered.
// G3 CAP: for every registered market, covered <= lifetimeCap, ALWAYS. Cap can
// never be exceeded; the cap check applies to the CLAMPED payout amount
// (min(request, owed)), so a request over the raw cap that clamps under it
// still succeeds, while a clamped amount over the cap reverts CapExceeded.
// G4 NO-VALUE-CREATION: cumulative coverage paid in a token never exceeds
// cumulative donations of that token (can't pay out value that never came in).
// G5 COOLDOWN: two successful coverages on the same market are always at least
// COOLDOWN_SECONDS apart; a second cover inside the window reverts.
// G6 DEBT-ONLY-DECREASES: fund actions never mint or increase any borrower's
// debt; repayOnBehalfOf can only REDUCE it, by exactly the tokens paid.
// G7 COVER-NEEDS-REAL-DEBT: coverage is only ever paid against a borrower who
// actually owes, and the paid amount is clamped to the outstanding debt;
// covering a no-debt account reverts NoDebtToCover.
// G8 NO-ESCAPE-HATCH: the ONLY state-mutating externals are
// {proposeMarket, registerMarket, cancelPendingMarket, donate, coverBadDebt}
// and coverBadDebt is the ONLY path that removes tokens -- there is no
// withdraw / rescue / sweep / emergency / owner-drain function, and only
// FOUNDATION can call the payout path or propose a market.
//
// No em-dashes anywhere in this file.
// =============================================================================
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");
/* ------------------------------- seeded PRNG ------------------------------ */
function mulberry32(a) {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0x1F5EED;
function rngFor(tag, campaign) {
let h = BASE_SEED ^ (campaign * 0x9e3779b1);
for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0;
return mulberry32(h >>> 0);
}
const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive
const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)];
const chance = (rng, p) => rng() < p;
/* ------------------------------- misc helpers ----------------------------- */
const ONE6 = 10n ** 6n; // USDC.e style debt token
const ONE18 = 10n ** 18n; // collateral
const REG_DELAY = 7 * 86400 + 5;
const N = {
CAMPAIGNS: Number(process.env.IF_CAMPAIGNS || 3),
STEPS: Number(process.env.IF_STEPS || 60),
MARKETS: Number(process.env.IF_MARKETS || 2),
BORROWERS: Number(process.env.IF_BORROWERS || 2),
};
// =============================================================================
describe("INVARIANT: AereInsuranceFund (bad-debt coverage, custody + bounded payout)", function () {
this.timeout(0);
let signers;
let ERC20F, AggF, OracleF, WAEREF, BurnF, SinkF, MarketF, FundF;
before(async function () {
signers = await ethers.getSigners();
ERC20F = await ethers.getContractFactory("MockERC20Lending");
AggF = await ethers.getContractFactory("MockAggregator");
OracleF = await ethers.getContractFactory("AereLendingOracle");
WAEREF = await ethers.getContractFactory("contracts/WAERE.sol:WAERE");
BurnF = await ethers.getContractFactory("AereFeeBurnVault");
SinkF = await ethers.getContractFactory("AereSink");
MarketF = await ethers.getContractFactory("AereLendingMarket");
FundF = await ethers.getContractFactory("AereInsuranceFund");
console.log(
` [ifund] base seed 0x${BASE_SEED.toString(16)} | ` +
`${N.CAMPAIGNS} campaigns x ${N.STEPS} steps, ${N.MARKETS} markets/${N.BORROWERS} borrowers each`
);
});
// Shared infra (oracle + sink) for a campaign. Debt token is shared across
// all markets in a campaign so the balances/donation/coverage accounting is a
// single, aggregated ledger (the hardest case to keep consistent).
async function deployInfra(deployer) {
const debt = await ERC20F.deploy("USDC.e", "USDC.e", 6);
await debt.waitForDeployment();
const aggD = await AggF.deploy(1_00000000); // $1.00, 8 decimals
await aggD.waitForDeployment();
const oracle = await OracleF.deploy();
await oracle.waitForDeployment();
await oracle.configureFeed(await debt.getAddress(), 0, await aggD.getAddress(), ethers.ZeroHash, 30 * 86400, 0, 8);
const waere = await WAEREF.deploy();
await waere.waitForDeployment();
const burn = await BurnF.deploy();
await burn.waitForDeployment();
const sink = await SinkF.deploy(
await waere.getAddress(), await burn.getAddress(),
await waere.getAddress(), await waere.getAddress(),
1500, 4000, 4500, 200, ethers.ZeroAddress
);
await sink.waitForDeployment();
return { debt, aggD, oracle, sink };
}
// Build one market on shared infra + manufacture real BadDebt on `borrowers`
// via an insolvent liquidation. Interest is frozen so residual debt is static.
async function buildMarketWithBadDebt(infra, deployer, supplier, liquidator, borrowers) {
const collateral = await ERC20F.deploy("BUIDL.e", "BUIDL.e", 18);
await collateral.waitForDeployment();
const aggC = await AggF.deploy(1_00000000); // $1.00 to start
await aggC.waitForDeployment();
await infra.oracle.configureFeed(await collateral.getAddress(), 0, await aggC.getAddress(), ethers.ZeroHash, 30 * 86400, 0, 8);
const cfg = {
collateral: await collateral.getAddress(),
debtToken: await infra.debt.getAddress(),
sink: await infra.sink.getAddress(),
oracle: await infra.oracle.getAddress(),
ltvBps: 7000, liqThresholdBps: 8000, liqBonusBps: 500, borrowFeeBps: 100,
marketDebtCap: 1_000_000_000n * ONE6,
collateralDecimals: 18, debtDecimals: 6,
};
const market = await MarketF.deploy(cfg);
await market.waitForDeployment();
const marketAddr = await market.getAddress();
// Freeze interest so debt accounting stays exact across time warps.
await (await market.connect(deployer).setBorrowRate(0)).wait();
// Supplier provides borrowable liquidity.
await (await infra.debt.mint(supplier.address, 50_000_000n * ONE6)).wait();
await (await infra.debt.connect(supplier).approve(marketAddr, ethers.MaxUint256)).wait();
await (await market.connect(supplier).supply(20_000_000n * ONE6)).wait();
// Each borrower posts 1000 collateral @ $1, borrows 690 (< 70% LTV).
for (const b of borrowers) {
await (await collateral.mint(b.address, 1000n * ONE18)).wait();
await (await collateral.connect(b).approve(marketAddr, ethers.MaxUint256)).wait();
await (await market.connect(b).depositCollateral(1000n * ONE18)).wait();
await (await market.connect(b).borrow(690n * ONE6)).wait();
}
// Crash collateral to $0.05 -> deeply insolvent, then liquidate fully.
await (await aggC.set(5_000_000)).wait();
await (await market.accrueInterest()).wait();
await (await infra.debt.mint(liquidator.address, BigInt(borrowers.length) * 1000n * ONE6)).wait();
await (await infra.debt.connect(liquidator).approve(marketAddr, ethers.MaxUint256)).wait();
for (const b of borrowers) {
await (await market.connect(liquidator).liquidate(b.address, 690n * ONE6)).wait();
const resid = await market.debtBalance(b.address);
expect(resid, `bad-debt setup produced no residual for ${b.address}`).to.be.gt(0n);
}
return { market, marketAddr, collateral, aggC };
}
// Structural proof: the fund has no escape hatch and only Foundation controls
// the privileged paths. Run once per campaign against the deployed instance.
function assertNoEscapeHatch(fund) {
const allowedMutators = new Set([
"proposeMarket", "registerMarket", "cancelPendingMarket", "donate", "coverBadDebt",
]);
const banned = /withdraw|rescue|sweep|emergency|drain|transferOwnership|renounce|setOwner|setFoundation|migrate|skim|recover/i;
for (const f of fund.interface.fragments) {
if (f.type !== "function") continue;
if (f.stateMutability === "view" || f.stateMutability === "pure") continue;
expect(banned.test(f.name), `[ifund G8] fund exposes suspicious mutator "${f.name}"`).to.equal(false);
expect(allowedMutators.has(f.name), `[ifund G8] unexpected state-mutating external "${f.name}"`).to.equal(true);
}
}
// The global invariant battery -- asserted after EVERY step.
async function assertInvariants(ctx, fund, debt, ledger) {
const debtAddr = await debt.getAddress();
const tracked = await fund.balances(debtAddr);
const real = await debt.balanceOf(await fund.getAddress());
// G1 custody-balance: real == tracked + stray, and tracked <= real always.
expect(real, `[ifund G1] real balance != tracked+stray ${ctx} tracked=${tracked} stray=${ledger.stray} real=${real}`)
.to.equal(tracked + ledger.stray);
expect(tracked, `[ifund G1b] tracked mapping exceeds real balance ${ctx}`).to.be.lte(real);
// G2 accounting-conservation.
expect(tracked, `[ifund G2] balances != donated-covered ${ctx} donated=${ledger.donated} covered=${ledger.covered}`)
.to.equal(ledger.donated - ledger.covered);
// G4 no-value-creation.
expect(ledger.covered, `[ifund G4] covered ${ledger.covered} > donated ${ledger.donated} ${ctx}`)
.to.be.lte(ledger.donated);
// G3 cap per market + cooldown-spacing bookkeeping consistency.
for (const m of ledger.markets) {
const info = await fund.markets(m.addr);
// info = [registered, lifetimeCap, covered, lastCoverageAt]
expect(info[2], `[ifund G3] market covered ${info[2]} > cap ${info[1]} ${ctx} market=${m.addr.slice(0, 8)}`)
.to.be.lte(info[1]);
// on-chain covered must match our independently-tracked per-market total.
expect(info[2], `[ifund G3b] per-market covered drift ${ctx} market=${m.addr.slice(0, 8)}`).to.equal(m.covered);
}
// G6 debt-only-decreases: no borrower debt ever exceeds its recorded high-water.
for (const b of ledger.borrowers) {
const d = await b.market.debtBalance(b.addr);
expect(d, `[ifund G6] borrower debt rose ${ctx} borrower=${b.addr.slice(0, 8)} was<=${b.debt} now=${d}`)
.to.be.lte(b.debt);
b.debt = d; // ratchet down
}
}
// -------------------------------------------------------------------------
it("multi-market / multi-actor coverage: custody, bounded payout, cap+cooldown, conservation", async function () {
const deployer = signers[0];
let totalSteps = 0, covers = 0, capReverts = 0, cooldownReverts = 0,
noDebtReverts = 0, foundationReverts = 0, donations = 0, strays = 0, insufReverts = 0;
for (let c = 0; c < N.CAMPAIGNS; c++) {
const rng = rngFor("main", c);
const foundation = signers[1];
const supplier = signers[2];
const liquidator = signers[3];
const donors = [signers[4], signers[5], signers[6]];
const attackers = [signers[7], signers[8]];
// borrower pool: MARKETS * BORROWERS distinct addresses from a stable base.
const borrowerSigners = signers.slice(9, 9 + N.MARKETS * N.BORROWERS);
const infra = await deployInfra(deployer);
const debt = infra.debt;
const debtAddr = await debt.getAddress();
const fund = await FundF.deploy(foundation.address, ri(rng, 3600, 2 * 86400)); // 1h..2d cooldown
await fund.waitForDeployment();
const fundAddr = await fund.getAddress();
const cooldown = await fund.COOLDOWN_SECONDS();
assertNoEscapeHatch(fund);
// Access-control probes: attackers can neither propose nor cover.
await expect(
fund.connect(attackers[0]).proposeMarket(borrowerSigners[0].address, 1n),
"[ifund G8] attacker proposeMarket not rejected"
).to.be.revertedWithCustomError(fund, "NotFoundation");
const ledger = {
donated: 0n, covered: 0n, stray: 0n,
markets: [], borrowers: [],
};
// Build markets + bad debt, register each with a random lifetime cap.
let bi = 0;
for (let mi = 0; mi < N.MARKETS; mi++) {
const brs = borrowerSigners.slice(bi, bi + N.BORROWERS);
bi += N.BORROWERS;
const mk = await buildMarketWithBadDebt(infra, deployer, supplier, liquidator, brs);
// Cap chosen to sometimes be tight (forces CapExceeded) and sometimes loose.
const cap = BigInt(ri(rng, 200, 4000)) * ONE6;
await (await fund.connect(foundation).proposeMarket(mk.marketAddr, cap)).wait();
ledger.markets.push({ addr: mk.marketAddr, market: mk.market, cap, covered: 0n });
for (const b of brs) {
ledger.borrowers.push({ addr: b.address, market: mk.market, debt: await mk.market.debtBalance(b.address) });
}
}
// Elapse the 7-day registration timelock once, then execute all pending.
await time.increase(REG_DELAY);
await network.provider.send("evm_mine");
for (const m of ledger.markets) {
await (await fund.connect(pick(rng, [foundation, ...attackers])).registerMarket(m.addr)).wait();
}
// Seed the fund with an initial donation.
const seed = BigInt(ri(rng, 3000, 12000)) * ONE6;
await (await debt.mint(donors[0].address, seed)).wait();
await (await debt.connect(donors[0]).approve(fundAddr, ethers.MaxUint256)).wait();
await (await fund.connect(donors[0]).donate(debtAddr, seed)).wait();
ledger.donated += seed;
donations++;
await assertInvariants(`c${c} init`, fund, debt, ledger);
// Non-foundation cover must be rejected (state-independent guard).
await expect(
fund.connect(attackers[1]).coverBadDebt(ledger.markets[0].addr, ledger.borrowers[0].addr, 1n),
"[ifund G8] attacker coverBadDebt not rejected"
).to.be.revertedWithCustomError(fund, "NotFoundation");
foundationReverts++;
for (let s = 0; s < N.STEPS; s++) {
const action = pick(rng, [
"cover", "cover", "cover", "cover",
"donate", "donate", "stray",
"advance", "advance",
"coverAttacker", "coverNoDebt", "coverExceedCap", "advance",
]);
const ctx = `c${c} s${s} action=${action}`;
try {
if (action === "donate") {
const donor = pick(rng, donors);
const amt = BigInt(ri(rng, 1, 5000)) * ONE6;
await (await debt.mint(donor.address, amt)).wait();
await (await debt.connect(donor).approve(fundAddr, amt)).wait();
await (await fund.connect(donor).donate(debtAddr, amt)).wait();
ledger.donated += amt;
donations++;
} else if (action === "stray") {
// Untracked direct transfer: must inflate real balance ONLY, never
// the tracked mapping, and never enable extra payout (G1/G1b).
const donor = pick(rng, donors);
const amt = BigInt(ri(rng, 1, 2000)) * ONE6;
await (await debt.mint(donor.address, amt)).wait();
await (await debt.connect(donor).transfer(fundAddr, amt)).wait();
ledger.stray += amt;
strays++;
} else if (action === "advance") {
await time.increase(ri(rng, 1, 3 * 86400));
await network.provider.send("evm_mine");
} else if (action === "cover") {
const m = pick(rng, ledger.markets);
const brs = ledger.borrowers.filter((b) => b.market === m.market);
const b = pick(rng, brs);
const owed = await m.market.debtBalance(b.addr);
const remaining = await fund.coverageRemaining(m.addr);
const ready = await fund.cooldownReady(m.addr);
const tracked = await fund.balances(debtAddr);
// Random request: sometimes partial, sometimes >= owed (forces clamp),
// sometimes tiny.
let amount;
const k = rng();
if (k < 0.3) amount = BigInt(ri(rng, 1, 50)) * ONE6;
else if (k < 0.7) amount = owed === 0n ? 1n : (owed * BigInt(ri(rng, 1, 90))) / 100n;
else amount = owed + BigInt(ri(rng, 0, 500)) * ONE6;
if (amount === 0n) amount = 1n;
const clamped = amount > owed ? owed : amount;
const willCapRevert = clamped > remaining; // cap applies to the CLAMPED payout
const willSucceed =
ready && !willCapRevert && owed > 0n && tracked >= clamped;
const beforeFund = await debt.balanceOf(fundAddr);
const beforeMarket = await debt.balanceOf(m.addr);
const beforeDebt = owed;
const info = await fund.markets(m.addr);
const lastAt = info[3];
if (willSucceed) {
const tx = await fund.connect(foundation).coverBadDebt(m.addr, b.addr, amount);
const rc = await tx.wait();
const blk = await ethers.provider.getBlock(rc.blockNumber);
const afterFund = await debt.balanceOf(fundAddr);
const afterMarket = await debt.balanceOf(m.addr);
const afterDebt = await m.market.debtBalance(b.addr);
const delta = beforeFund - afterFund;
// bounded payout == exactly the clamped amount, nothing more.
expect(delta, `[ifund PAYOUT] paid ${delta} != clamped ${clamped} ${ctx}`).to.equal(clamped);
// market received exactly what the fund paid (no leakage / mint).
expect(afterMarket - beforeMarket, `[ifund PAYOUT-MKT] market delta != paid ${ctx}`).to.equal(delta);
// borrower debt fell by exactly the paid amount (G6 / G7 clamp).
expect(beforeDebt - afterDebt, `[ifund PAYOUT-DEBT] debt drop != paid ${ctx} before=${beforeDebt} after=${afterDebt}`).to.equal(delta);
// tracked mapping fell by the same amount (G2).
const afterTracked = await fund.balances(debtAddr);
expect(tracked - afterTracked, `[ifund PAYOUT-TRACK] tracked delta != paid ${ctx}`).to.equal(delta);
// cap accounting.
const info2 = await fund.markets(m.addr);
expect(info2[2] - info[2], `[ifund PAYOUT-COVERED] covered delta != paid ${ctx}`).to.equal(delta);
// G5 cooldown spacing: timestamp advanced to now, and if a prior
// cover existed the gap is >= cooldown.
expect(info2[3], `[ifund COOLDOWN-STAMP] lastCoverageAt != block time ${ctx}`).to.equal(BigInt(blk.timestamp));
if (lastAt !== 0n) {
expect(BigInt(blk.timestamp) - lastAt, `[ifund G5] cover inside cooldown window ${ctx}`).to.be.gte(cooldown);
}
// borrower value did NOT increase (G6). Ratchet the high-water down.
const bl = ledger.borrowers.find((x) => x.addr === b.addr && x.market === b.market);
expect(afterDebt, `[ifund G6] debt increased on cover ${ctx}`).to.be.lte(bl.debt);
bl.debt = afterDebt;
ledger.covered += delta;
m.covered += delta;
covers++;
} else {
// Expected to revert. Confirm it does AND that no state moved.
let reverted = false;
try {
await (await fund.connect(foundation).coverBadDebt(m.addr, b.addr, amount)).wait();
} catch (e) {
reverted = true;
if (/CapExceeded/.test(e.message)) capReverts++;
else if (/CooldownActive/.test(e.message)) cooldownReverts++;
else if (/NoDebtToCover/.test(e.message)) noDebtReverts++;
else if (/InsufficientBalance/.test(e.message)) insufReverts++;
}
expect(reverted, `[ifund PREDICT] expected cover revert but it succeeded ${ctx} amount=${amount} owed=${owed} remaining=${remaining} ready=${ready} tracked=${tracked}`).to.equal(true);
// state unchanged.
expect(await debt.balanceOf(fundAddr), `[ifund NOMOVE-fund] ${ctx}`).to.equal(beforeFund);
expect((await fund.markets(m.addr))[2], `[ifund NOMOVE-covered] ${ctx}`).to.equal(info[2]);
}
} else if (action === "coverAttacker") {
// Payout path is Foundation-only regardless of state.
const m = pick(rng, ledger.markets);
const b = pick(rng, ledger.borrowers.filter((x) => x.market === m.market));
await expect(
fund.connect(pick(rng, attackers)).coverBadDebt(m.addr, b.addr, 1n * ONE6),
`[ifund G8] non-foundation cover succeeded ${ctx}`
).to.be.revertedWithCustomError(fund, "NotFoundation");
foundationReverts++;
} else if (action === "coverNoDebt") {
// Foundation covering an address with zero debt must revert -- but the
// contract checks cooldown BEFORE the debt check, so only assert
// NoDebtToCover when the cooldown is actually clear.
const m = pick(rng, ledger.markets);
if (await fund.cooldownReady(m.addr)) {
const remaining = await fund.coverageRemaining(m.addr);
if (remaining > 0n) {
await expect(
fund.connect(foundation).coverBadDebt(m.addr, attackers[0].address, 1n),
`[ifund G7] no-debt cover not rejected ${ctx}`
).to.be.revertedWithCustomError(fund, "NoDebtToCover");
noDebtReverts++;
}
}
} else if (action === "coverExceedCap") {
// Post-clamp cap semantics: CapExceeded fires only when the amount
// that would ACTUALLY be paid (min(request, owed)) exceeds the
// remaining cap. Send more than owed so the clamp binds, and only
// assert the revert when owed itself is over the remaining cap; if
// owed <= remaining the correct behavior is a clamped SUCCESS, not a
// revert, so we do not force one here.
const m = pick(rng, ledger.markets);
if (await fund.cooldownReady(m.addr)) {
const remaining = await fund.coverageRemaining(m.addr);
const b = pick(rng, ledger.borrowers.filter((x) => x.market === m.market));
const owed = await m.market.debtBalance(b.addr);
if (owed > remaining) {
const amount = owed + 1n; // clamps to owed, which is over remaining
const beforeFund = await debt.balanceOf(fundAddr);
await expect(
fund.connect(foundation).coverBadDebt(m.addr, b.addr, amount),
`[ifund G3] over-cap cover not rejected ${ctx} remaining=${remaining} owed=${owed}`
).to.be.revertedWithCustomError(fund, "CapExceeded");
expect(await debt.balanceOf(fundAddr), `[ifund G3 NOMOVE] ${ctx}`).to.equal(beforeFund);
capReverts++;
}
}
}
} catch (e) {
if (/\[ifund /.test(String(e.message))) throw e; // real invariant break
// Otherwise an incidental revert on an edge amount is legal; fall through.
}
await assertInvariants(ctx, fund, debt, ledger);
totalSteps++;
}
// Finale: verify the ledger closes out consistently against ground truth.
await assertInvariants(`c${c} finale`, fund, debt, ledger);
}
console.log(
` [ifund main] steps=${totalSteps} covers=${covers} donations=${donations} strays=${strays} ` +
`capRev=${capReverts} cooldownRev=${cooldownReverts} noDebtRev=${noDebtReverts} insufRev=${insufReverts} foundationRev=${foundationReverts}`
);
expect(totalSteps).to.be.greaterThan(N.CAMPAIGNS * N.STEPS - 1);
expect(covers, "no successful coverages exercised").to.be.greaterThan(10);
expect(capReverts + cooldownReverts + noDebtReverts, "guard reverts never exercised").to.be.greaterThan(3);
expect(foundationReverts, "access-control path never exercised").to.be.greaterThan(3);
});
// -------------------------------------------------------------------------
// Production-parameter campaign: the LIVE fund runs a 7-day cooldown. Prove the
// exact "cannot exceed cap within a cooldown window" bound holds at that value:
// after one cover, a second is impossible for 7 days, and cap is a hard lifetime
// ceiling even across many windows.
it("production 7-day cooldown: one cover per window, cap is a hard lifetime ceiling", async function () {
const rng = rngFor("prod", 0);
const deployer = signers[0];
const foundation = signers[1];
const supplier = signers[2];
const liquidator = signers[3];
const donor = signers[4];
const borrowers = [signers[9], signers[10]];
const infra = await deployInfra(deployer);
const debt = infra.debt;
const debtAddr = await debt.getAddress();
const SEVEN_DAYS = 7n * 86400n;
const fund = await FundF.deploy(foundation.address, SEVEN_DAYS);
await fund.waitForDeployment();
const fundAddr = await fund.getAddress();
assertNoEscapeHatch(fund);
const mk = await buildMarketWithBadDebt(infra, deployer, supplier, liquidator, borrowers);
const cap = 300n * ONE6; // small lifetime cap
await (await fund.connect(foundation).proposeMarket(mk.marketAddr, cap)).wait();
await time.increase(REG_DELAY);
await network.provider.send("evm_mine");
await (await fund.connect(foundation).registerMarket(mk.marketAddr)).wait();
await (await debt.mint(donor.address, 100_000n * ONE6)).wait();
await (await debt.connect(donor).approve(fundAddr, ethers.MaxUint256)).wait();
await (await fund.connect(donor).donate(debtAddr, 100_000n * ONE6)).wait();
let covered = 0n, windows = 0;
// Cover 50 USDC.e per window; cap is 300 so exactly 6 windows then hard stop.
for (let w = 0; w < 10; w++) {
const remaining = await fund.coverageRemaining(mk.marketAddr);
const b = borrowers[w % borrowers.length].address;
const owed = await mk.market.debtBalance(b);
const amount = 50n * ONE6;
if (remaining === 0n) {
await expect(
fund.connect(foundation).coverBadDebt(mk.marketAddr, b, amount),
`[ifund PROD-CAP] cover after cap exhausted (w=${w})`
).to.be.revertedWithCustomError(fund, "CapExceeded");
continue;
}
expect(owed, `[ifund PROD] borrower unexpectedly debt-free w=${w}`).to.be.gt(0n);
const before = await debt.balanceOf(fundAddr);
await (await fund.connect(foundation).coverBadDebt(mk.marketAddr, b, amount)).wait();
const delta = before - (await debt.balanceOf(fundAddr));
expect(delta, `[ifund PROD] paid != 50 w=${w}`).to.equal(amount);
covered += delta;
windows++;
// Immediately trying again (same block-ish, within 7 days) must revert.
await expect(
fund.connect(foundation).coverBadDebt(mk.marketAddr, b, amount),
`[ifund G5 PROD] second cover inside 7-day window succeeded (w=${w})`
).to.be.revertedWithCustomError(fund, "CooldownActive");
// Advance just under 7 days: still locked.
await time.increase(Number(SEVEN_DAYS) - 60);
await network.provider.send("evm_mine");
if ((await fund.coverageRemaining(mk.marketAddr)) > 0n) {
await expect(
fund.connect(foundation).coverBadDebt(mk.marketAddr, b, amount),
`[ifund G5 PROD] cover at T-60s succeeded (w=${w})`
).to.be.revertedWithCustomError(fund, "CooldownActive");
}
// Cross the boundary.
await time.increase(120);
await network.provider.send("evm_mine");
}
// Exactly cap/50 = 6 successful windows, covered == cap, and the mapping /
// real balance agree.
expect(windows, "[ifund PROD] wrong number of covered windows").to.equal(6);
expect(covered, "[ifund PROD] total covered != cap").to.equal(cap);
const info = await fund.markets(mk.marketAddr);
expect(info[2], "[ifund PROD] on-chain covered != cap").to.equal(cap);
expect(await fund.coverageRemaining(mk.marketAddr), "[ifund PROD] remaining not zero").to.equal(0n);
expect(await debt.balanceOf(fundAddr), "[ifund PROD G1] balance mismatch")
.to.equal(await fund.balances(debtAddr));
expect(100_000n * ONE6 - (await fund.balances(debtAddr)), "[ifund PROD G2] conservation")
.to.equal(cap);
console.log(` [ifund prod] 7-day windows covered=${windows} totalCovered=${ethers.formatUnits(covered, 6)} cap=${ethers.formatUnits(cap, 6)}`);
});
});