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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.