Advanced

Lending Protocols (Mechanics) — Project — minimal lending pool

Understand collateral, liquidation, and health factor.

Developer Advanced
7/8 — Lending Protocols (Mechanics) — Project — minimal lending pool

What to writeDeposit/withdraw, borrow/repay, interest accrual, and liquidation with oracle checks.

  1. State & params
    Keep reserves, indexes, risk params, and oracle handle.
    IERC20 public immutable asset;
    IAggregatorV3Interface public immutable feed;
    uint256 public totalBorrows;
    uint256 public totalSupply;
    uint256 public borrowIndex = 1e18;
    uint256 public supplyIndex = 1e18;
    uint256 public reserveFactor = 1e17; // 10%
    uint256 public collateralFactor = 7e17; // 70%
    uint256 public liqThreshold = 8e17; // 80%
    uint256 public closeFactor = 5e17; // 50%
    uint256 public liqBonus = 105e16; // 5% bonus
    uint256 public reserves;
    mapping(address => uint256) public supplyBalanceIndex;
    mapping(address => uint256) public borrowBalanceIndex;
    mapping(address => uint256) public supplyPrincipal;
    mapping(address => uint256) public borrowPrincipal;

    Indexes keep per-user balances proportional as interest accrues.

  2. Accrue interest
    Update indexes on every action using utilization.
    function accrue() public {
        uint256 cash = asset.balanceOf(address(this)) - reserves;
        uint256 util = totalBorrows == 0 ? 0 : (totalBorrows * 1e18) / (cash + totalBorrows);
        uint256 borrowRate = util / 10; // toy model: 10% at util=1
        uint256 supplyRate = borrowRate * (1e18 - reserveFactor) / 1e18;
        borrowIndex = borrowIndex + (borrowIndex * borrowRate) / 1e18;
        supplyIndex = supplyIndex + (supplyIndex * supplyRate) / 1e18;
        uint256 interest = (totalBorrows * borrowRate) / 1e18;
        totalBorrows += interest;
        reserves += (interest * reserveFactor) / 1e18;
    }

    Borrow index grows debt; supply index grows depositor claims net of reserves.

  3. Update user balances
    Bring a user to the latest index before mutating principal.
    function _accrueUser(address user) internal {
        uint256 si = supplyIndex;
        uint256 bi = borrowIndex;
        uint256 supIdx = supplyBalanceIndex[user];
        uint256 borIdx = borrowBalanceIndex[user];
        if (supIdx == 0) supIdx = 1e18;
        if (borIdx == 0) borIdx = 1e18;
        supplyPrincipal[user] = (supplyPrincipal[user] * si) / supIdx;
        borrowPrincipal[user] = (borrowPrincipal[user] * bi) / borIdx;
        supplyBalanceIndex[user] = si;
        borrowBalanceIndex[user] = bi;
    }

    Stops index drift before deposits/borrows/repays.

  4. Health factor view
    Price collateral/debt and return HF.
    function healthFactor(address user) public view returns (uint256 hf) {
        (, int256 price,, uint256 updated,) = feed.latestRoundData();
        require(updated + 1 hours > block.timestamp, "stale");
        uint256 p = uint256(price); // assume 8 decimals
        uint256 col = (supplyPrincipal[user] * p) / 1e8;
        uint256 debt = (borrowPrincipal[user] * p) / 1e8;
        if (debt == 0) return type(uint256).max;
        hf = (col * liqThreshold) / 1e18 / debt;
    }

    Requires fresh oracle data; scales by liqThreshold.

  5. Borrow / repay
    Respect HF and update principals with indexes.
    function borrow(uint256 amount) external {
        accrue();
        _accrueUser(msg.sender);
        uint256 newDebt = borrowPrincipal[msg.sender] + amount;
        borrowPrincipal[msg.sender] = newDebt;
        totalBorrows += amount;
        require(healthFactor(msg.sender) >= 1e18, "HF");
        asset.transfer(msg.sender, amount);
        emit Borrow(msg.sender, amount, newDebt);
    }
    
    function repay(uint256 amount) external {
        accrue();
        _accrueUser(msg.sender);
        uint256 debt = borrowPrincipal[msg.sender];
        uint256 pay = amount > debt ? debt : amount;
        borrowPrincipal[msg.sender] = debt - pay;
        totalBorrows -= pay;
        asset.transferFrom(msg.sender, address(this), pay);
        emit Repay(msg.sender, pay, borrowPrincipal[msg.sender]);
    }

    HF check gates borrows; repay clamps to outstanding debt.

  6. Liquidation
    Repay up to closeFactor and seize with bonus.
    function liquidate(address user, uint256 repayAmount) external {
        accrue();
        _accrueUser(user);
        require(healthFactor(user) < 1e18, "healthy");
        uint256 maxRepay = (borrowPrincipal[user] * closeFactor) / 1e18;
        uint256 pay = repayAmount > maxRepay ? maxRepay : repayAmount;
        asset.transferFrom(msg.sender, address(this), pay);
        borrowPrincipal[user] -= pay;
        totalBorrows -= pay;
        uint256 seize = (pay * liqBonus) / 1e18;
        uint256 sup = supplyPrincipal[user];
        require(seize <= sup, "not enough");
        supplyPrincipal[user] = sup - seize;
        supplyPrincipal[msg.sender] += seize;
        emit Liquidate(msg.sender, user, pay, seize);
    }

    Seizes depositor balance with a bonus; HF improves after repayment.

Why Index-based accounting plus HF checks keeps math transparent and testable.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IAggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract MiniLending {
    IERC20 public immutable asset;
    IAggregatorV3Interface public immutable feed;
    uint256 public totalBorrows;
    uint256 public totalSupply;
    uint256 public borrowIndex = 1e18;
    uint256 public supplyIndex = 1e18;
    uint256 public reserveFactor = 1e17;
    uint256 public collateralFactor = 7e17;
    uint256 public liqThreshold = 8e17;
    uint256 public closeFactor = 5e17;
    uint256 public liqBonus = 105e16;
    uint256 public reserves;
    mapping(address => uint256) public supplyBalanceIndex;
    mapping(address => uint256) public borrowBalanceIndex;
    mapping(address => uint256) public supplyPrincipal;
    mapping(address => uint256) public borrowPrincipal;

    event Deposit(address indexed user, uint256 amount);
    event Withdraw(address indexed user, uint256 amount);
    event Borrow(address indexed user, uint256 amount, uint256 newDebt);
    event Repay(address indexed user, uint256 amount, uint256 newDebt);
    event Liquidate(address indexed liquidator, address indexed user, uint256 repaid, uint256 seized);

    constructor(address _asset, address _feed) {
        asset = IERC20(_asset);
        feed = IAggregatorV3Interface(_feed);
    }

    function accrue() public {
        uint256 cash = asset.balanceOf(address(this)) - reserves;
        uint256 util = totalBorrows == 0 ? 0 : (totalBorrows * 1e18) / (cash + totalBorrows);
        uint256 borrowRate = util / 10;
        uint256 supplyRate = borrowRate * (1e18 - reserveFactor) / 1e18;
        borrowIndex = borrowIndex + (borrowIndex * borrowRate) / 1e18;
        supplyIndex = supplyIndex + (supplyIndex * supplyRate) / 1e18;
        uint256 interest = (totalBorrows * borrowRate) / 1e18;
        totalBorrows += interest;
        reserves += (interest * reserveFactor) / 1e18;
    }

    function _accrueUser(address user) internal {
        uint256 si = supplyIndex;
        uint256 bi = borrowIndex;
        uint256 supIdx = supplyBalanceIndex[user];
        uint256 borIdx = borrowBalanceIndex[user];
        if (supIdx == 0) supIdx = 1e18;
        if (borIdx == 0) borIdx = 1e18;
        supplyPrincipal[user] = (supplyPrincipal[user] * si) / supIdx;
        borrowPrincipal[user] = (borrowPrincipal[user] * bi) / borIdx;
        supplyBalanceIndex[user] = si;
        borrowBalanceIndex[user] = bi;
    }

    function _price() internal view returns (uint256 p) {
        (, int256 answer,, uint256 updated,) = feed.latestRoundData();
        require(updated + 1 hours > block.timestamp, "stale");
        p = uint256(answer); // assume 8 decimals
    }

    function healthFactor(address user) public view returns (uint256 hf) {
        uint256 p = _price();
        uint256 col = (supplyPrincipal[user] * p) / 1e8;
        uint256 debt = (borrowPrincipal[user] * p) / 1e8;
        if (debt == 0) return type(uint256).max;
        hf = (col * liqThreshold) / 1e18 / debt;
    }

    function deposit(uint256 amount) external {
        accrue();
        _accrueUser(msg.sender);
        asset.transferFrom(msg.sender, address(this), amount);
        supplyPrincipal[msg.sender] += amount;
        totalSupply += amount;
        emit Deposit(msg.sender, amount);
    }

    function withdraw(uint256 amount) external {
        accrue();
        _accrueUser(msg.sender);
        require(amount <= supplyPrincipal[msg.sender], "exceeds");
        supplyPrincipal[msg.sender] -= amount;
        totalSupply -= amount;
        require(healthFactor(msg.sender) >= 1e18, "HF");
        asset.transfer(msg.sender, amount);
        emit Withdraw(msg.sender, amount);
    }

    function borrow(uint256 amount) external {
        accrue();
        _accrueUser(msg.sender);
        borrowPrincipal[msg.sender] += amount;
        totalBorrows += amount;
        require(healthFactor(msg.sender) >= 1e18, "HF");
        asset.transfer(msg.sender, amount);
        emit Borrow(msg.sender, amount, borrowPrincipal[msg.sender]);
    }

    function repay(uint256 amount) external {
        accrue();
        _accrueUser(msg.sender);
        uint256 debt = borrowPrincipal[msg.sender];
        uint256 pay = amount > debt ? debt : amount;
        borrowPrincipal[msg.sender] = debt - pay;
        totalBorrows -= pay;
        asset.transferFrom(msg.sender, address(this), pay);
        emit Repay(msg.sender, pay, borrowPrincipal[msg.sender]);
    }

    function liquidate(address user, uint256 repayAmount) external {
        accrue();
        _accrueUser(user);
        require(healthFactor(user) < 1e18, "healthy");
        uint256 maxRepay = (borrowPrincipal[user] * closeFactor) / 1e18;
        uint256 pay = repayAmount > maxRepay ? maxRepay : repayAmount;
        asset.transferFrom(msg.sender, address(this), pay);
        borrowPrincipal[user] -= pay;
        totalBorrows -= pay;
        uint256 seize = (pay * liqBonus) / 1e18;
        uint256 sup = supplyPrincipal[user];
        require(seize <= sup, "not enough");
        supplyPrincipal[user] = sup - seize;
        supplyPrincipal[msg.sender] += seize;
        emit Liquidate(msg.sender, user, pay, seize);
    }
}

Try itWrite invariants: HF >= 1 after deposits/repays; HF drops only on price/interest moves; liquidations improve HF; stale oracle reverts borrows.

Key points
  • State & params:Track totalBorrows, borrowIndex, supplyIndex, reserves, collateral/liq thresholds (1e18), closeFactor, liqBonus, reserveFactor, and the price feed.
  • Accrual:On each action, accrue interest using utilization; update borrowIndex and supplyIndex; skim reserveFactor into reserves.
  • Borrow/repay:Check HF before new debt; update per-user principal with borrowIndex; repay reduces debt and updates indexes; emit Borrow/Repay.
  • Liquidation path:If HF < 1, allow up to closeFactor of debt to be repaid; seize collateral with liqBonus; emit Liquidate; recompute HF after.
  • Oracle guard:Validate freshness/decimals; if stale/invalid, block new borrows and signal via event or revert; keep a pause flag for borrows only.
  • Observability:Events for Deposit/Withdraw/Borrow/Repay/Liquidate plus view helpers (HF, utilization, rates) for UIs and risk dashboards.
← Previous section
Next section →