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

502 lines
23 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title AereLendingMarket — isolated single-pair money market
* @notice One deployment per (collateral, debt) pair. Foundation sets
* immutable risk params at deploy; suppliers earn yield; borrowers
* pay interest; AereSink receives a configurable cut of interest
* (NOT principal) on every accrual via `pendingSinkFee`.
*
* WAVE 1 PAIRS planned:
* Pair | LTV | LiqThr | LiqBonus | Borrow fee bps
* BUIDL → USDC.e | 90% | 92% | 1% | 10
* USDY → USDC.e | 88% | 91% | 1% | 10
* OUSG → USDC.e | 88% | 91% | 1% | 10
* LBTC → USDC.e | 70% | 78% | 5% | 30
* SolvBTC → USDC.e | 65% | 73% | 6% | 35
* pumpBTC → USDC.e | 60% | 70% | 7% | 40
*
* ACCOUNTING (scaled-principal with global accrual indices):
* - `debtOf[user]` is the SCALED principal (in ray, 1e27).
* Real debt = debtOf[user] * borrowIndex / 1e27.
* - `supplierScaled[user]` is the SCALED supply. Real supply =
* supplierScaled[user] * supplyIndex / 1e27.
* - `borrowIndex` grows at `borrowRatePerSecond * dt` per accrue.
* - `supplyIndex` grows proportionally so suppliers receive
* (1 - feeBps) of every interest tick. Fee accumulates as
* `pendingSinkFee` (in DEBT_TOKEN units) and anyone can flush
* it to AereSink via `flushSinkFee()`.
*
* INSOLVENT LIQUIDATIONS:
* - When the borrower's collateral can't cover the requested pay
* plus bonus, we PROPORTIONALLY REDUCE `pay` so the liquidator
* only purchases what they receive. The residual `unpaid`
* stays on `debtOf[borrower]` and is emitted as `BadDebt`. A
* future AereInsuranceFund (or socialised write-down) can
* clear it; the market never silently masks bad debt.
*
* SAFETY:
* - Per-asset immutable risk params (constructor).
* - Oracle deviation + staleness guarded by AereLendingOracle.
* - ReentrancyGuard on all mutating fns.
* - Pause-borrow / pause-deposit toggles for emergency.
* LIQUIDATIONS NEVER PAUSE.
* - No admin withdrawal: Foundation cannot rug supplier deposits.
*
* @dev Single-market isolation (one contract = one collateral/debt pair)
* is chosen over a unified Comptroller pattern for clean blast-radius:
* a SolvBTC oracle compromise can only hurt the SolvBTC market, not
* BUIDL.
*/
contract AereLendingMarket is Ownable, ReentrancyGuard {
uint256 internal constant RAY = 1e27;
/* ------------------------------- immutable ------------------------------- */
IERC20 public immutable COLLATERAL;
IERC20 public immutable DEBT_TOKEN;
address public immutable SINK;
address public immutable ORACLE;
uint16 public immutable LTV_BPS;
uint16 public immutable LIQ_THRESHOLD_BPS;
uint16 public immutable LIQ_BONUS_BPS;
uint16 public immutable BORROW_FEE_BPS;
uint256 public immutable MARKET_DEBT_CAP;
uint8 public immutable COLLATERAL_DECIMALS;
uint8 public immutable DEBT_DECIMALS;
/* --------------------------------- state -------------------------------- */
/// @notice user → collateral deposited (in COLLATERAL token units)
mapping(address => uint256) public collateralOf;
/// @notice user → SCALED debt principal (multiply by borrowIndex/RAY for real)
mapping(address => uint256) public debtOf;
/// @notice user → SCALED supplier balance (multiply by supplyIndex/RAY for real)
mapping(address => uint256) public supplierScaled;
/// @notice cumulative indices (ray, 1e27)
uint256 public borrowIndex = RAY;
uint256 public supplyIndex = RAY;
/// @notice last block.timestamp `accrueInterest` ran
uint256 public lastAccrual;
/// @notice total scaled debt principal (sum of debtOf)
uint256 public totalBorrowsScaled;
/// @notice total scaled supplier principal (sum of supplierScaled)
uint256 public totalSuppliedScaled;
/// @notice DEBT_TOKEN sitting in the contract earmarked for AereSink
uint256 public pendingSinkFee;
/// @notice borrow rate in ray (1e27) per second. Default ~5% APY:
/// 0.05 * 1e27 / (365 * 86400) ≈ 1.585e18.
uint256 public borrowRatePerSecond = 1_585_489_599_188_229_300;
bool public borrowPaused;
bool public depositPaused;
/* --------------------------------- events ------------------------------- */
event Deposit(address indexed user, uint256 amount);
event Withdraw(address indexed user, uint256 amount);
event Supply(address indexed user, uint256 amount, uint256 scaled);
event Redeem(address indexed user, uint256 amount, uint256 scaled);
event Borrow(address indexed user, uint256 amount, uint256 scaled);
event Repay(address indexed user, uint256 amount, uint256 scaled);
event Liquidated(address indexed liquidator, address indexed borrower, uint256 repaid, uint256 collateralSeized);
event BadDebt(address indexed borrower, uint256 unpaidDebt);
event InterestAccrued(uint256 newBorrowIndex, uint256 newSupplyIndex, uint256 interest, uint256 feeToSink);
event SinkFeeFlushed(uint256 amount);
event BorrowRateUpdated(uint256 newRatePerSecond);
event PausedSet(bool borrowPaused, bool depositPaused);
/* --------------------------------- errors ------------------------------- */
error ZeroAmount();
error InsufficientCollateral();
error InsufficientDebtLiquidity();
error PositionHealthy();
error PositionUnhealthy();
error MarketCapReached();
error TransferFailed();
error BorrowPausedErr();
error DepositPausedErr();
error InsufficientSupplierBalance();
/* ----------------------------- constructor ------------------------------ */
struct Config {
address collateral;
address debtToken;
address sink;
address oracle;
uint16 ltvBps;
uint16 liqThresholdBps;
uint16 liqBonusBps;
uint16 borrowFeeBps;
uint256 marketDebtCap;
uint8 collateralDecimals;
uint8 debtDecimals;
}
constructor(Config memory c) {
require(c.collateral != address(0) && c.debtToken != address(0), "zero token");
require(c.sink != address(0) && c.oracle != address(0), "zero infra");
require(c.ltvBps <= c.liqThresholdBps && c.liqThresholdBps < 10_000, "thresholds");
require(c.liqBonusBps < 5_000, "bonus huge");
require(c.borrowFeeBps <= 5_000, "fee huge");
COLLATERAL = IERC20(c.collateral);
DEBT_TOKEN = IERC20(c.debtToken);
SINK = c.sink;
ORACLE = c.oracle;
LTV_BPS = c.ltvBps;
LIQ_THRESHOLD_BPS = c.liqThresholdBps;
LIQ_BONUS_BPS = c.liqBonusBps;
BORROW_FEE_BPS = c.borrowFeeBps;
MARKET_DEBT_CAP = c.marketDebtCap;
COLLATERAL_DECIMALS = c.collateralDecimals;
DEBT_DECIMALS = c.debtDecimals;
lastAccrual = block.timestamp;
}
/* ----------------------------- interest accrual ------------------------- */
/// @notice Grow borrowIndex + supplyIndex over elapsed seconds, accumulate sink fee.
function accrueInterest() public {
uint256 dt = block.timestamp - lastAccrual;
if (dt == 0) return;
lastAccrual = block.timestamp;
if (totalBorrowsScaled == 0) return;
uint256 borrowsBefore = (totalBorrowsScaled * borrowIndex) / RAY;
// borrowIndex grows as (1 + rate * dt) — linear approximation safe at 0.5s blocks.
uint256 indexFactor = (borrowRatePerSecond * dt); // ray
uint256 borrowsAfter = borrowsBefore + (borrowsBefore * indexFactor) / RAY;
uint256 interest = borrowsAfter - borrowsBefore;
if (interest == 0) {
borrowIndex += (borrowIndex * indexFactor) / RAY;
emit InterestAccrued(borrowIndex, supplyIndex, 0, 0);
return;
}
borrowIndex += (borrowIndex * indexFactor) / RAY;
uint256 fee = (interest * BORROW_FEE_BPS) / 10_000;
uint256 toSuppliers = interest - fee;
// Grow supplyIndex proportionally so suppliers' real balance
// increases by `toSuppliers` in aggregate.
uint256 suppliedReal = (totalSuppliedScaled * supplyIndex) / RAY;
if (suppliedReal > 0 && toSuppliers > 0) {
// newSupplyIndex / oldSupplyIndex = (suppliedReal + toSuppliers) / suppliedReal
supplyIndex = (supplyIndex * (suppliedReal + toSuppliers)) / suppliedReal;
pendingSinkFee += fee;
} else {
// ROUND-3 FIX: borrows outstanding with no live supplier-claims
// (e.g. all suppliers withdrew via socialised path) would silently
// leak `toSuppliers` into raw contract balance — uncredited. Route
// 100% of the interest tick to AereSink instead.
pendingSinkFee += interest;
}
emit InterestAccrued(borrowIndex, supplyIndex, interest, fee);
}
/// @notice Permissionless: push accumulated fee to AereSink.
function flushSinkFee() external nonReentrant {
uint256 amount = pendingSinkFee;
if (amount == 0) return;
// Reset before external call (CEI).
pendingSinkFee = 0;
// Use forceApprove pattern: zero then set, to support USDT-like tokens.
DEBT_TOKEN.approve(SINK, 0);
DEBT_TOKEN.approve(SINK, amount);
(bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", address(DEBT_TOKEN), amount));
if (!ok) {
// Restore so a later call can retry.
pendingSinkFee = amount;
DEBT_TOKEN.approve(SINK, 0);
revert TransferFailed();
}
emit SinkFeeFlushed(amount);
}
/* ------------------------------ supply side ----------------------------- */
function supply(uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
accrueInterest();
if (!DEBT_TOKEN.transferFrom(msg.sender, address(this), amount)) revert TransferFailed();
uint256 scaled = (amount * RAY) / supplyIndex;
supplierScaled[msg.sender] += scaled;
totalSuppliedScaled += scaled;
emit Supply(msg.sender, amount, scaled);
}
function redeem(uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
accrueInterest();
uint256 userReal = (supplierScaled[msg.sender] * supplyIndex) / RAY;
if (userReal < amount) revert InsufficientSupplierBalance();
if (_availableLiquidity() < amount + pendingSinkFee) revert InsufficientDebtLiquidity();
uint256 scaled = (amount * RAY) / supplyIndex;
// Round-up protection: never let scaled exceed user's holding.
if (scaled > supplierScaled[msg.sender]) scaled = supplierScaled[msg.sender];
supplierScaled[msg.sender] -= scaled;
totalSuppliedScaled -= scaled;
if (!DEBT_TOKEN.transfer(msg.sender, amount)) revert TransferFailed();
emit Redeem(msg.sender, amount, scaled);
}
function supplierBalance(address u) external view returns (uint256) {
// Lazy-view: doesn't accrue. UI should call accrueInterest first for live yield.
return (supplierScaled[u] * supplyIndex) / RAY;
}
function supplierPrincipal(address u) external view returns (uint256) {
// Legacy compat — same as scaled balance pre-interest.
return supplierScaled[u];
}
/* ----------------------------- borrower side ---------------------------- */
function depositCollateral(uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
if (depositPaused) revert DepositPausedErr();
accrueInterest();
if (!COLLATERAL.transferFrom(msg.sender, address(this), amount)) revert TransferFailed();
collateralOf[msg.sender] += amount;
emit Deposit(msg.sender, amount);
}
function withdrawCollateral(uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
accrueInterest();
if (collateralOf[msg.sender] < amount) revert InsufficientCollateral();
uint256 newCol = collateralOf[msg.sender] - amount;
uint256 debtReal = _realDebt(msg.sender);
if (debtReal > 0 && !_healthyAt(newCol, debtReal)) revert PositionUnhealthy();
collateralOf[msg.sender] = newCol;
if (!COLLATERAL.transfer(msg.sender, amount)) revert TransferFailed();
emit Withdraw(msg.sender, amount);
}
function borrow(uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
if (borrowPaused) revert BorrowPausedErr();
accrueInterest();
uint256 currentTotalReal = (totalBorrowsScaled * borrowIndex) / RAY;
if (currentTotalReal + amount > MARKET_DEBT_CAP) revert MarketCapReached();
// Must leave pendingSinkFee untouched for sink claim.
if (_availableLiquidity() < amount + pendingSinkFee) revert InsufficientDebtLiquidity();
uint256 borrowedScaled = (amount * RAY) / borrowIndex;
// Round-up to avoid undercharging on dust.
if ((borrowedScaled * borrowIndex) / RAY < amount) borrowedScaled += 1;
debtOf[msg.sender] += borrowedScaled;
totalBorrowsScaled += borrowedScaled;
uint256 newDebtReal = _realDebt(msg.sender);
if (!_healthyAt(collateralOf[msg.sender], newDebtReal)) revert PositionUnhealthy();
if (!DEBT_TOKEN.transfer(msg.sender, amount)) revert TransferFailed();
emit Borrow(msg.sender, amount, borrowedScaled);
}
function repay(uint256 amount) external nonReentrant {
_repayInternal(msg.sender, msg.sender, amount);
}
/// @notice Permissionless: anyone (typically AereInsuranceFund or a
/// charitable donor) can pay down `account`'s debt. msg.sender
/// provides the tokens; `account`'s debt and totalBorrowsScaled
/// are reduced. No bonus, no liquidation — just pure repayment
/// at face value. Useful for clearing BadDebt residuals after
/// insolvent liquidations.
function repayOnBehalfOf(address account, uint256 amount) external nonReentrant {
_repayInternal(msg.sender, account, amount);
}
function _repayInternal(address payer, address account, uint256 amount) internal {
if (amount == 0) revert ZeroAmount();
accrueInterest();
uint256 debtReal = _realDebt(account);
if (debtReal == 0) return;
uint256 pay = amount > debtReal ? debtReal : amount;
if (!DEBT_TOKEN.transferFrom(payer, address(this), pay)) revert TransferFailed();
uint256 repaidScaled = (pay * RAY) / borrowIndex;
if (pay == debtReal) {
repaidScaled = debtOf[account];
}
debtOf[account] -= repaidScaled;
totalBorrowsScaled -= repaidScaled;
emit Repay(account, pay, repaidScaled);
}
/* ------------------------------ liquidation ----------------------------- */
/// @notice Liquidate `borrower`. Liquidator pays up to `repayAmount` of
/// the debt and receives proportional collateral + bonus.
/// Insolvent case (collateral ≤ value-needed): we proportionally
/// reduce the actual `pay` charged so the liquidator buys only
/// what they receive. Residual debt stays on the borrower and
/// is emitted as `BadDebt`.
function liquidate(address borrower, uint256 repayAmount) external nonReentrant {
if (repayAmount == 0) revert ZeroAmount();
accrueInterest();
uint256 collateral = collateralOf[borrower];
uint256 debtReal = _realDebt(borrower);
if (debtReal == 0) revert PositionHealthy();
if (_healthyAtLiqThreshold(collateral, debtReal)) revert PositionHealthy();
uint256 pay = repayAmount > debtReal ? debtReal : repayAmount;
// LIQUIDATIONS NEVER PAUSE: use the non-freezing liquidation price so a
// paused/deviating/stale feed can never block clearing bad debt.
uint256 debtPrice = _priceLiq(address(DEBT_TOKEN));
uint256 colPrice = _priceLiq(address(COLLATERAL));
// value in 18-dec USD
uint256 valueRepaid = _scaleUp(pay, DEBT_DECIMALS) * debtPrice / 1e18;
uint256 valueToSeize = valueRepaid + (valueRepaid * LIQ_BONUS_BPS) / 10_000;
uint256 collateralToSeize = _scaleDown(valueToSeize * 1e18 / colPrice, COLLATERAL_DECIMALS);
// If we'd seize more than borrower has, clamp seize AND scale pay down
// proportionally so liquidator only pays for what they receive.
if (collateralToSeize > collateral) {
collateralToSeize = collateral;
uint256 valueOfClamped = _scaleUp(collateral, COLLATERAL_DECIMALS) * colPrice / 1e18;
// valueOfClamped = valueRepaidReduced * (1 + LIQ_BONUS_BPS/10_000)
uint256 valueRepaidReduced = (valueOfClamped * 10_000) / (10_000 + LIQ_BONUS_BPS);
pay = _scaleDown(valueRepaidReduced * 1e18 / debtPrice, DEBT_DECIMALS);
if (pay > debtReal) pay = debtReal;
}
if (!DEBT_TOKEN.transferFrom(msg.sender, address(this), pay)) revert TransferFailed();
// Update borrower state (use scaled units to stay consistent with index).
uint256 repaidScaled = (pay * RAY) / borrowIndex;
if (repaidScaled > debtOf[borrower]) repaidScaled = debtOf[borrower];
debtOf[borrower] -= repaidScaled;
totalBorrowsScaled -= repaidScaled;
collateralOf[borrower] = collateral - collateralToSeize;
// Emit any residual debt that remains on a now-zero-collateral borrower.
uint256 remainingCol = collateralOf[borrower];
uint256 remainingDebt = _realDebt(borrower);
if (remainingCol == 0 && remainingDebt > 0) {
emit BadDebt(borrower, remainingDebt);
}
if (!COLLATERAL.transfer(msg.sender, collateralToSeize)) revert TransferFailed();
emit Liquidated(msg.sender, borrower, pay, collateralToSeize);
}
/* ----------------------------- admin / governance ----------------------- */
function setBorrowRate(uint256 newRatePerSecondRay) external onlyOwner {
// Cap at 1000% APY (10x) in ray/sec — bounds extreme misconfiguration.
// 10 * 1e27 / (365 * 86400) ≈ 3.17e20.
require(newRatePerSecondRay <= 317_097_919_837_645_865_000, "rate too high");
accrueInterest();
borrowRatePerSecond = newRatePerSecondRay;
emit BorrowRateUpdated(newRatePerSecondRay);
}
function setPaused(bool _borrowPaused, bool _depositPaused) external onlyOwner {
borrowPaused = _borrowPaused;
depositPaused = _depositPaused;
emit PausedSet(_borrowPaused, _depositPaused);
}
/* ---------------------------------- views ------------------------------- */
/// @notice Returns position health as a ratio scaled by 10_000 (1.0 = 10_000).
/// Above 10_000 = healthy. Below 10_000 = liquidatable.
function healthFactor(address user) external view returns (uint256) {
uint256 debt = _realDebt(user);
if (debt == 0) return type(uint256).max;
uint256 col = collateralOf[user];
uint256 colVal = _scaleUp(col, COLLATERAL_DECIMALS) * _price(address(COLLATERAL)) / 1e18;
uint256 debtVal = _scaleUp(debt, DEBT_DECIMALS) * _price(address(DEBT_TOKEN)) / 1e18;
return (colVal * LIQ_THRESHOLD_BPS) / debtVal;
}
function debtBalance(address user) external view returns (uint256) {
return _realDebt(user);
}
function totalBorrowsCurrent() external view returns (uint256) {
return (totalBorrowsScaled * borrowIndex) / RAY;
}
function totalSuppliedCurrent() external view returns (uint256) {
return (totalSuppliedScaled * supplyIndex) / RAY;
}
/* ------------------------------ internal -------------------------------- */
function _realDebt(address user) internal view returns (uint256) {
if (debtOf[user] == 0) return 0;
return (debtOf[user] * borrowIndex) / RAY;
}
function _healthyAt(uint256 col, uint256 debt) internal view returns (bool) {
if (debt == 0) return true;
uint256 colVal = _scaleUp(col, COLLATERAL_DECIMALS) * _price(address(COLLATERAL)) / 1e18;
uint256 debtVal = _scaleUp(debt, DEBT_DECIMALS) * _price(address(DEBT_TOKEN)) / 1e18;
return colVal * LTV_BPS / 10_000 >= debtVal;
}
/// @dev Liquidation-eligibility check. Uses the non-freezing liquidation
/// price so a paused/deviating/stale feed cannot block a liquidation
/// that the "LIQUIDATIONS NEVER PAUSE" invariant promises must proceed.
function _healthyAtLiqThreshold(uint256 col, uint256 debt) internal view returns (bool) {
if (debt == 0) return true;
uint256 colVal = _scaleUp(col, COLLATERAL_DECIMALS) * _priceLiq(address(COLLATERAL)) / 1e18;
uint256 debtVal = _scaleUp(debt, DEBT_DECIMALS) * _priceLiq(address(DEBT_TOKEN)) / 1e18;
return colVal * LIQ_THRESHOLD_BPS / 10_000 >= debtVal;
}
function _price(address asset) internal view returns (uint256) {
return IAereOracleAdapterLending(ORACLE).getPrice(asset);
}
/// @dev Non-freezing price for the liquidation path only (never pauses, never
/// applies the deviation cap or staleness revert, falls back to the last
/// good price). Borrow-side checks keep the strict _price/getPrice so the
/// circuit-breaker still gates NEW borrows. See AereLendingOracle.
function _priceLiq(address asset) internal view returns (uint256) {
return IAereOracleAdapterLending(ORACLE).getPriceForLiquidation(asset);
}
function _availableLiquidity() internal view returns (uint256) {
return DEBT_TOKEN.balanceOf(address(this));
}
function _scaleUp(uint256 amount, uint8 dec) internal pure returns (uint256) {
if (dec == 18) return amount;
if (dec < 18) return amount * (10 ** (18 - dec));
return amount / (10 ** (dec - 18));
}
function _scaleDown(uint256 amount, uint8 dec) internal pure returns (uint256) {
if (dec == 18) return amount;
if (dec < 18) return amount / (10 ** (18 - dec));
return amount * (10 ** (dec - 18));
}
}
interface IAereOracleAdapterLending {
function getPrice(address asset) external view returns (uint256);
function getPriceForLiquidation(address asset) external view returns (uint256);
}