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.
601 lines
30 KiB
JavaScript
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)}`);
|
|
});
|
|
});
|