aere-contracts/test/lending.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

328 lines
15 KiB
JavaScript

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