// AereLendingMarket + AereLendingOracle tests. // Updated after H1/H2/H3/H5 fixes: scaled-principal accounting + supplyIndex // + insolvent-liquidation clamp + oracle auto-poke + owner-only poke. const { expect } = require("chai"); const { ethers } = require("hardhat"); const ONE6 = 10n ** 6n; const ONE18 = 10n ** 18n; const RAY = 10n ** 27n; async function deployStack(opts = {}) { const ltvBps = opts.ltvBps ?? 9000; const liqThresholdBps = opts.liqThresholdBps ?? 9200; const liqBonusBps = opts.liqBonusBps ?? 100; const borrowFeeBps = opts.borrowFeeBps ?? 1000; // 10% of interest by default — sharp for visible test math const ERC20 = await ethers.getContractFactory("MockERC20Lending"); const collateral = await ERC20.deploy("BUIDL.e", "BUIDL.e", 18); await collateral.waitForDeployment(); const debt = await ERC20.deploy("USDC.e", "USDC.e", 6); await debt.waitForDeployment(); const Agg = await ethers.getContractFactory("MockAggregator"); const aggC = await Agg.deploy(1_00000000); // $1 await aggC.waitForDeployment(); const aggD = await Agg.deploy(1_00000000); // $1 await aggD.waitForDeployment(); const Oracle = await ethers.getContractFactory("AereLendingOracle"); const oracle = await Oracle.deploy(); await oracle.waitForDeployment(); // configureFeed AUTO-POKES now — lastGoodPrice locked at config time. // Deviation cap is configurable per test scenario (default 50% for liquidation tests // that simulate big crash; tests that target deviation behaviour override explicitly). const devBps = opts.devBps ?? 5000; await oracle.configureFeed(await collateral.getAddress(), 0, await aggC.getAddress(), ethers.ZeroHash, 86400, devBps, 8); await oracle.configureFeed(await debt.getAddress(), 0, await aggD.getAddress(), ethers.ZeroHash, 86400, devBps, 8); const WAERE = await (await ethers.getContractFactory("contracts/WAERE.sol:WAERE")).deploy(); await WAERE.waitForDeployment(); const burn = await (await ethers.getContractFactory("AereFeeBurnVault")).deploy(); await burn.waitForDeployment(); const sink = await (await ethers.getContractFactory("AereSink")).deploy( await WAERE.getAddress(), await burn.getAddress(), await WAERE.getAddress(), await WAERE.getAddress(), 1500, 4000, 4500, 200, ethers.ZeroAddress ); await sink.waitForDeployment(); const cfg = { collateral: await collateral.getAddress(), debtToken: await debt.getAddress(), sink: await sink.getAddress(), oracle: await oracle.getAddress(), ltvBps, liqThresholdBps, liqBonusBps, borrowFeeBps, marketDebtCap: 1_000_000n * ONE6, collateralDecimals: 18, debtDecimals: 6, }; const Market = await ethers.getContractFactory("AereLendingMarket"); const market = await Market.deploy(cfg); await market.waitForDeployment(); return { collateral, debt, oracle, market, aggC, aggD, sink }; } async function advance(seconds) { await ethers.provider.send("evm_increaseTime", [seconds]); await ethers.provider.send("evm_mine", []); } describe("AereLendingMarket — accounting (H1/H2 fixes)", function () { it("supplier earns yield via supplyIndex (H2 fix)", async function () { const { collateral, debt, market } = await deployStack(); const [, alice, bob] = await ethers.getSigners(); // Alice supplies 100,000 USDC.e. await debt.mint(alice.address, 100_000n * ONE6); await debt.connect(alice).approve(await market.getAddress(), 100_000n * ONE6); await market.connect(alice).supply(100_000n * ONE6); const aliceBefore = await market.supplierBalance(alice.address); expect(aliceBefore).to.equal(100_000n * ONE6); // Bob borrows 50,000 against 100,000 BUIDL.e collateral. await collateral.mint(bob.address, 100_000n * ONE18); await collateral.connect(bob).approve(await market.getAddress(), 100_000n * ONE18); await market.connect(bob).depositCollateral(100_000n * ONE18); await market.connect(bob).borrow(50_000n * ONE6); // Advance 1 year. await advance(365 * 24 * 3600); await market.accrueInterest(); // Alice's real balance now > principal. const aliceAfter = await market.supplierBalance(alice.address); expect(aliceAfter).to.be.gt(aliceBefore); // ~5% APY * 50% utilisation * 90% net (10% to sink) ≈ 2.25% on full supply // Sanity floor: at least 1% growth. expect(aliceAfter - aliceBefore).to.be.gt(1000n * ONE6); }); it("sink fee accumulates as pendingSinkFee (H2 fix) + flushable", async function () { const { collateral, debt, market } = await deployStack({ borrowFeeBps: 2000 }); const [, alice, bob] = await ethers.getSigners(); await debt.mint(alice.address, 100_000n * ONE6); await debt.connect(alice).approve(await market.getAddress(), 100_000n * ONE6); await market.connect(alice).supply(100_000n * ONE6); await collateral.mint(bob.address, 100_000n * ONE18); await collateral.connect(bob).approve(await market.getAddress(), 100_000n * ONE18); await market.connect(bob).depositCollateral(100_000n * ONE18); await market.connect(bob).borrow(50_000n * ONE6); await advance(365 * 24 * 3600); await market.accrueInterest(); const pending = await market.pendingSinkFee(); expect(pending).to.be.gt(0n); // flushSinkFee zeroes the pending. await market.flushSinkFee(); expect(await market.pendingSinkFee()).to.equal(0n); }); it("totalBorrowsCurrent grows with interest; cap still enforced (H1 fix)", async function () { const { collateral, debt, market } = await deployStack(); const [, alice, bob] = await ethers.getSigners(); await debt.mint(alice.address, 500_000n * ONE6); await debt.connect(alice).approve(await market.getAddress(), 500_000n * ONE6); await market.connect(alice).supply(500_000n * ONE6); await collateral.mint(bob.address, 100_000n * ONE18); await collateral.connect(bob).approve(await market.getAddress(), 100_000n * ONE18); await market.connect(bob).depositCollateral(100_000n * ONE18); await market.connect(bob).borrow(50_000n * ONE6); const t0 = await market.totalBorrowsCurrent(); expect(t0).to.equal(50_000n * ONE6); await advance(180 * 24 * 3600); await market.accrueInterest(); const t1 = await market.totalBorrowsCurrent(); expect(t1).to.be.gt(t0); }); it("repay correctly reduces scaled debt; no underflow on totalBorrows", async function () { const { collateral, debt, market } = await deployStack(); const [, alice, bob] = await ethers.getSigners(); await debt.mint(alice.address, 100_000n * ONE6); await debt.connect(alice).approve(await market.getAddress(), 100_000n * ONE6); await market.connect(alice).supply(100_000n * ONE6); await collateral.mint(bob.address, 100_000n * ONE18); await collateral.connect(bob).approve(await market.getAddress(), 100_000n * ONE18); await market.connect(bob).depositCollateral(100_000n * ONE18); await market.connect(bob).borrow(50_000n * ONE6); await advance(30 * 24 * 3600); await market.accrueInterest(); const owed = await market.debtBalance(bob.address); expect(owed).to.be.gt(50_000n * ONE6); // Overpay slightly so that even if interest accrues between view + tx, // the contract clamps `pay` to the exact `debtReal` and fully zeros state. const overpay = owed + 1000n * ONE6; await debt.mint(bob.address, overpay); await debt.connect(bob).approve(await market.getAddress(), overpay); await market.connect(bob).repay(overpay); expect(await market.debtBalance(bob.address)).to.equal(0n); expect(await market.totalBorrowsScaled()).to.equal(0n); }); }); describe("AereLendingMarket — liquidation (H3 fix)", function () { it("partial liquidation routes correctly", async function () { const { collateral, debt, market, aggC } = await deployStack({ ltvBps: 7000, liqThresholdBps: 8000, liqBonusBps: 500, }); const [, alice, bob, keeper] = await ethers.getSigners(); await debt.mint(alice.address, 100_000n * ONE6); await debt.connect(alice).approve(await market.getAddress(), 100_000n * ONE6); await market.connect(alice).supply(100_000n * ONE6); await collateral.mint(bob.address, 1000n * ONE18); await collateral.connect(bob).approve(await market.getAddress(), 1000n * ONE18); await market.connect(bob).depositCollateral(1000n * ONE18); await market.connect(bob).borrow(700n * ONE6); await aggC.set(80000000); // $0.80 — liquidatable await market.accrueInterest(); await debt.mint(keeper.address, 500n * ONE6); await debt.connect(keeper).approve(await market.getAddress(), 500n * ONE6); await expect(market.connect(keeper).liquidate(bob.address, 300n * ONE6)) .to.emit(market, "Liquidated"); }); it("insolvent position: clamps pay AND emits BadDebt (H3 fix)", async function () { const { collateral, debt, market, aggC } = await deployStack({ ltvBps: 7000, liqThresholdBps: 8000, liqBonusBps: 500, devBps: 0, // disable deviation cap so the simulated crash works }); const [, alice, bob, keeper] = await ethers.getSigners(); await debt.mint(alice.address, 100_000n * ONE6); await debt.connect(alice).approve(await market.getAddress(), 100_000n * ONE6); await market.connect(alice).supply(100_000n * ONE6); await collateral.mint(bob.address, 100n * ONE18); await collateral.connect(bob).approve(await market.getAddress(), 100n * ONE18); await market.connect(bob).depositCollateral(100n * ONE18); await market.connect(bob).borrow(60n * ONE6); // 60% LTV at $1/$1 — safe // Collateral crashes to $0.30 — collateral worth $30, debt $60 → deeply insolvent. await aggC.set(30000000); await market.accrueInterest(); await debt.mint(keeper.address, 200n * ONE6); await debt.connect(keeper).approve(await market.getAddress(), 200n * ONE6); const keeperBefore = await debt.balanceOf(keeper.address); const colBefore = await collateral.balanceOf(keeper.address); await expect(market.connect(keeper).liquidate(bob.address, 60n * ONE6)) .to.emit(market, "BadDebt"); const keeperAfter = await debt.balanceOf(keeper.address); const colAfter = await collateral.balanceOf(keeper.address); const paid = keeperBefore - keeperAfter; const received = colAfter - colBefore; // Liquidator received some collateral and paid LESS than requested 60. expect(received).to.be.gt(0n); expect(paid).to.be.lt(60n * ONE6); // Bob's collateral is now zero; debt residual remains. expect(await market.collateralOf(bob.address)).to.equal(0n); expect(await market.debtBalance(bob.address)).to.be.gt(0n); }); }); describe("AereLendingMarket — LIQUIDATIONS NEVER PAUSE (oracle-freeze fix, 2026-07-12)", function () { // Adversarial-review HIGH: getPrice()'s pause/deviation/staleness reverts froze // liquidate(), contradicting the "LIQUIDATIONS NEVER PAUSE" invariant. Fix: the // liquidation path reads getPriceForLiquidation() which never freezes. async function liquidatablePosition() { // 10% deviation cap so a 30% crash trips getPrice's deviation revert. const s = await deployStack({ ltvBps: 7000, liqThresholdBps: 8000, liqBonusBps: 500, devBps: 1000 }); const [, alice, bob, keeper] = await ethers.getSigners(); await s.debt.mint(alice.address, 100_000n * ONE6); await s.debt.connect(alice).approve(await s.market.getAddress(), 100_000n * ONE6); await s.market.connect(alice).supply(100_000n * ONE6); await s.collateral.mint(bob.address, 1000n * ONE18); await s.collateral.connect(bob).approve(await s.market.getAddress(), 1000n * ONE18); await s.market.connect(bob).depositCollateral(1000n * ONE18); await s.market.connect(bob).borrow(700n * ONE6); await s.debt.mint(keeper.address, 500n * ONE6); await s.debt.connect(keeper).approve(await s.market.getAddress(), 500n * ONE6); return { ...s, alice, bob, keeper }; } it("deviation-cap breach: getPrice() reverts, but liquidate() still succeeds", async function () { const { market, oracle, collateral, aggC, bob, keeper } = await liquidatablePosition(); await aggC.set(70000000); // $0.70, a 30% crash > the 10% deviation cap await market.accrueInterest(); await expect(oracle.getPrice(await collateral.getAddress())) .to.be.revertedWithCustomError(oracle, "FeedDeviationExceeded"); await expect(market.connect(keeper).liquidate(bob.address, 300n * ONE6)) .to.emit(market, "Liquidated"); }); it("paused feed: getPrice() reverts, but liquidate() still succeeds", async function () { const { market, oracle, collateral, aggC, bob, keeper } = await liquidatablePosition(); await aggC.set(70000000); await market.accrueInterest(); await oracle.setFeedPaused(await collateral.getAddress(), true); await expect(oracle.getPrice(await collateral.getAddress())) .to.be.revertedWithCustomError(oracle, "FeedPausedErr"); await expect(market.connect(keeper).liquidate(bob.address, 300n * ONE6)) .to.emit(market, "Liquidated"); }); it("stale feed: getPrice() reverts, but liquidate() still succeeds", async function () { const { market, oracle, collateral, aggC, bob, keeper } = await liquidatablePosition(); await aggC.setStale(70000000, 1); // crash reading, ancient timestamp await market.accrueInterest(); await expect(oracle.getPrice(await collateral.getAddress())) .to.be.revertedWithCustomError(oracle, "FeedStale"); await expect(market.connect(keeper).liquidate(bob.address, 300n * ONE6)) .to.emit(market, "Liquidated"); }); }); describe("AereLendingOracle — H5 fix", function () { it("configureFeed auto-pokes lastGoodPrice (closes deviation-skip exploit)", async function () { const [deployer] = await ethers.getSigners(); const Oracle = await ethers.getContractFactory("AereLendingOracle"); const oracle = await Oracle.deploy(); await oracle.waitForDeployment(); const Agg = await ethers.getContractFactory("MockAggregator"); const agg = await Agg.deploy(1_00000000); await agg.waitForDeployment(); const ERC20 = await ethers.getContractFactory("MockERC20Lending"); const t = await ERC20.deploy("X", "X", 18); await t.waitForDeployment(); await oracle.configureFeed(await t.getAddress(), 0, await agg.getAddress(), ethers.ZeroHash, 86400, 1000, 8); const feed = await oracle.feeds(await t.getAddress()); expect(feed.lastGoodPrice).to.equal(10n ** 18n); }); it("poke is owner-only", async function () { const { oracle, collateral } = await deployStack(); const [, alice] = await ethers.getSigners(); await expect(oracle.connect(alice).poke(await collateral.getAddress())) .to.be.revertedWith("Ownable: caller is not the owner"); }); it("pokeBounded permissionless but rejects out-of-band moves", async function () { const { oracle, collateral, aggC } = await deployStack({ devBps: 1000 }); // 10% cap const [, alice] = await ethers.getSigners(); await aggC.set(105_000_000); // $1.05 — within 10% deviation await oracle.connect(alice).pokeBounded(await collateral.getAddress()); const feed = await oracle.feeds(await collateral.getAddress()); expect(feed.lastGoodPrice).to.equal(105n * 10n ** 16n); // Now $1.50 — outside 10% cap from $1.05 reference. await aggC.set(150_000_000); await expect(oracle.connect(alice).pokeBounded(await collateral.getAddress())) .to.be.revertedWithCustomError(oracle, "FeedDeviationExceeded"); }); });