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