SC Day 77
Mini Lending — 借款 + 健康因子 + Chainlink 预言机
### 1. 借款流程
2026-06-26
第四阶段:综合实战 (73-80)lendingborrowrepayhealth-factorchainlinkoracleliquidation-risk
日期: 2026-06-26 方向: Solidity 阶段: 第四阶段:综合实战 (73-80) 标签: #lending #borrow #repay #health-factor #chainlink #oracle #liquidation-risk
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 借款机制、健康因子计算、Chainlink 预言机集成、清算阈值设计 |
| 实操 | 实现 borrow/repay 函数、healthFactor 计算、Chainlink 价格获取 |
| 产出 | 完整的借款功能 + Chainlink 集成 + 清算风险分析 |
核心概念
1. 借款流程
借款的核心逻辑:
用户请求借款 X 个 Token B
↓
检查用户是否有足够的抵押品(Token A)
↓
计算健康因子:是否安全?
HF = Σ(collateral_value * liquidation_threshold) / Σ(debt_value)
↓
如果 HF >= 1:允许借款
如果 HF < 1:拒绝借款
↓
更新借款记录
↓
转出 Token B 给用户
2. 借款索引(Borrow Index)机制
为了避免每次都遍历所有借款人更新利息,使用全局借款索引:
概念类似"每份额累计利息":
初始状态:
全局 borrowIndex = 1.0
用户 A 借了 1000 USDC,记录 userBorrowIndex = 1.0
一年后(假设利率 5%):
全局 borrowIndex = 1.05
用户 A 的实际债务 = 1000 * 1.05 / 1.0 = 1050 USDC
又一年后:
全局 borrowIndex = 1.1025 (复利)
用户 A 的实际债务 = 1000 * 1.1025 / 1.0 = 1102.5 USDC
用户 B 在此时借了 2000 USDC,记录 userBorrowIndex = 1.1025
用户 B 的实际债务 = 2000 * currentIndex / 1.1025
公式:
实际债务 = 记录的借款本金 * (当前全局 borrowIndex / 用户的 borrowIndex)
3. Chainlink 预言机集成
Chainlink 数据 Feed 架构
链下数据源 (多个独立 Oracle 节点)
↓
聚合合约 (AggregatorV3)
↓
价格 Feed 地址(每个交易对一个)
例如: ETH/USD → 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419
关键接口
interface AggregatorV3Interface {
function latestRoundData() external view returns (
uint80 roundId, // 轮次 ID
int256 answer, // 价格
uint256 startedAt, // 本轮开始时间
uint256 updatedAt, // 最后更新时间
uint80 answeredInRound // 回答的轮次
);
function decimals() external view returns (uint8);
function description() external view returns (string memory);
}
安全使用 Chainlink 的五个检查
function getPrice(address feed) internal view returns (uint256) {
AggregatorV3Interface priceFeed = AggregatorV3Interface(feed);
(
uint80 roundId,
int256 price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
// 检查 1:价格必须为正
require(price > 0, "Invalid price: negative or zero");
// 检查 2:数据新鲜度(不超过 1 小时)
require(
block.timestamp - updatedAt <= 3600,
"Stale price data"
);
// 检查 3:轮次完整性
require(
answeredInRound >= roundId,
"Incomplete round"
);
// 检查 4:roundId 不为 0(防止首轮异常)
require(roundId > 0, "Invalid round");
// 检查 5:标准化精度
uint8 feedDecimals = priceFeed.decimals();
if (feedDecimals < 18) {
return uint256(price) * 10 ** (18 - feedDecimals);
} else if (feedDecimals > 18) {
return uint256(price) / 10 ** (feedDecimals - 18);
}
return uint256(price);
}
4. 健康因子详解
健康因子 (Health Factor) 是衡量仓位安全性的核心指标:
HF = 加权抵押品价值 / 总债务价值
加权抵押品价值 = Σ(asset_balance * asset_price * liquidation_threshold)
总债务价值 = Σ(debt_balance * debt_price)
示例:
抵押品:10 ETH @ $2000, liquidation_threshold = 85%
加权抵押品: 10 * 2000 * 0.85 = $17,000
借款:10,000 USDC
总债务: $10,000
HF = 17,000 / 10,000 = 1.7 (安全)
ETH 跌到 $1200:
加权抵押品: 10 * 1200 * 0.85 = $10,200
HF = 10,200 / 10,000 = 1.02 (接近清算线!)
ETH 跌到 $1170:
加权抵押品: 10 * 1170 * 0.85 = $9,945
HF = 9,945 / 10,000 = 0.9945 (< 1, 可以被清算!)
5. 清算级联风险
ETH 价格下跌
↓
大量仓位 HF < 1
↓
清算人清算 → 抛售抵押品 ETH
↓
ETH 价格进一步下跌
↓
更多仓位 HF < 1
↓
更多清算 → 更多抛售
↓
死亡螺旋!
防护措施:
1. 设置不同的 LTV 和 Liquidation Threshold(缓冲区)
2. 清算奖励激励快速清算,避免堆积
3. 部分清算(不是一次清算全部)
4. Aave 的 Safety Module 作为最后防线
代码实战
Borrow/Repay 实现
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// 接续 Day 75 的 MiniLendingPool 合约
/// @title MiniLendingPool - 借款和还款功能
/// @notice Day 77 版本:新增 borrow/repay/healthFactor
contract MiniLendingPool_V2 is MiniLendingPool {
// ============ 新增错误 ============
error HealthFactorBelowOne();
error HealthFactorOk(); // 清算时使用
error MarketNotBorrowable();
error InvalidPrice();
error StalePrice();
// ============ 新增事件 ============
event Borrow(address indexed user, address indexed asset, uint256 amount);
event Repay(address indexed user, address indexed asset, uint256 amount);
// ============ 价格预言机 ============
mapping(address => address) public priceFeeds; // asset => Chainlink feed
uint256 public constant PRICE_PRECISION = 1e18;
uint256 public constant MAX_PRICE_AGE = 1 hours;
uint256 public constant HEALTH_FACTOR_THRESHOLD = 1e18; // HF = 1.0
// ============ 核心函数:借款 ============
/// @notice 从借贷池借出资产
/// @param asset 要借出的资产地址
/// @param amount 借出数量
function borrow(
address asset,
uint256 amount
) external nonReentrant onlyListedMarket(asset) {
if (amount == 0) revert ZeroAmount();
if (!marketConfigs[asset].canBeBorrowed) revert MarketNotBorrowable();
// 步骤 1:累计利息
_accrueInterest(asset);
MarketState storage state = marketStates[asset];
// 步骤 2:检查流动性
uint256 availableLiquidity = state.totalSupplyAssets - state.totalBorrows;
if (amount > availableLiquidity) revert InsufficientLiquidity();
// 步骤 3:更新用户借款记录
UserPosition storage position = positions[msg.sender][asset];
// 如果用户已有借款,先结算之前的利息
uint256 previousDebt = _getUserDebt(msg.sender, asset);
// 记录标准化借款金额
// normalizedDebt = amount / currentBorrowIndex
// 但为了精度,我们存储原始金额并记录当时的 borrowIndex
if (position.borrowBalance == 0) {
// 新借款
position.borrowBalance = amount;
position.userBorrowIndex = state.borrowIndex;
_addToBorrowList(msg.sender, asset);
} else {
// 追加借款:先将旧借款按当前索引标准化
position.borrowBalance = previousDebt + amount;
position.userBorrowIndex = state.borrowIndex;
}
// 步骤 4:更新全局总借款
state.totalBorrows += amount;
// 步骤 5:检查健康因子
uint256 hf = _calculateHealthFactor(msg.sender);
if (hf < HEALTH_FACTOR_THRESHOLD) revert HealthFactorBelowOne();
// 步骤 6:转出资产
IERC20(asset).safeTransfer(msg.sender, amount);
emit Borrow(msg.sender, asset, amount);
}
// ============ 核心函数:还款 ============
/// @notice 偿还借款
/// @param asset 偿还的资产地址
/// @param amount 偿还数量(type(uint256).max = 全部偿还)
function repay(
address asset,
uint256 amount
) external nonReentrant onlyListedMarket(asset) {
// 步骤 1:累计利息
_accrueInterest(asset);
// 步骤 2:计算当前债务
uint256 currentDebt = _getUserDebt(msg.sender, asset);
require(currentDebt > 0, "No debt");
// 处理全额还款
uint256 repayAmount = amount == type(uint256).max ? currentDebt : amount;
if (repayAmount > currentDebt) {
repayAmount = currentDebt; // 不超过实际债务
}
MarketState storage state = marketStates[asset];
UserPosition storage position = positions[msg.sender][asset];
// 步骤 3:更新用户借款记录
uint256 newDebt = currentDebt - repayAmount;
position.borrowBalance = newDebt;
position.userBorrowIndex = state.borrowIndex;
if (newDebt == 0) {
_removeFromBorrowList(msg.sender, asset);
}
// 步骤 4:更新全局总借款
state.totalBorrows -= repayAmount;
// 步骤 5:转入资产
IERC20(asset).safeTransferFrom(msg.sender, address(this), repayAmount);
emit Repay(msg.sender, asset, repayAmount);
}
// ============ 健康因子计算 ============
/// @notice 获取用户健康因子
/// @return hf 健康因子(18 位精度,1e18 = 1.0)
function getHealthFactor(address user) public view returns (uint256) {
return _calculateHealthFactor(user);
}
function _calculateHealthFactor(address user) internal view returns (uint256) {
(uint256 totalCollateralUSD, uint256 totalDebtUSD) = _getUserAccountData(user);
if (totalDebtUSD == 0) return type(uint256).max; // 无债务,HF 无穷大
// HF = totalCollateralUSD (加权) / totalDebtUSD
return totalCollateralUSD * PRECISION / totalDebtUSD;
}
/// @notice 获取用户账户数据
/// @return totalCollateralUSD 加权抵押品价值(已乘以 liquidation threshold)
/// @return totalDebtUSD 总债务价值
function _getUserAccountData(
address user
) internal view returns (uint256 totalCollateralUSD, uint256 totalDebtUSD) {
// 遍历所有市场计算抵押品和债务
for (uint256 i = 0; i < marketList.length; i++) {
address asset = marketList[i];
UserPosition storage position = positions[user][asset];
MarketConfig storage config = marketConfigs[asset];
uint256 assetPrice = _getAssetPriceUSD(asset);
uint8 assetDecimals = config.decimals;
// 计算抵押品价值(加权)
if (position.supplyShares > 0 && config.canBeCollateral) {
MarketState storage state = marketStates[asset];
uint256 supplyBalance = _convertToAssets(
position.supplyShares,
state.totalSupplyShares,
state.totalSupplyAssets
);
// 标准化到 18 位精度
uint256 collateralValueUSD = supplyBalance
* assetPrice / (10 ** assetDecimals);
// 乘以清算阈值
totalCollateralUSD += collateralValueUSD
* config.liquidationThresholdBps / PERCENTAGE_FACTOR;
}
// 计算债务价值
if (position.borrowBalance > 0) {
uint256 debtBalance = _getUserDebt(user, asset);
totalDebtUSD += debtBalance * assetPrice / (10 ** assetDecimals);
}
}
}
/// @notice 获取用户可借额度(基于 LTV 而非 Liquidation Threshold)
function getUserBorrowCapacity(
address user
) public view returns (uint256 capacityUSD) {
(uint256 totalCollateralUSD_LTV, uint256 totalDebtUSD) =
_getUserBorrowData(user);
if (totalCollateralUSD_LTV <= totalDebtUSD) return 0;
return totalCollateralUSD_LTV - totalDebtUSD;
}
function _getUserBorrowData(
address user
) internal view returns (uint256 totalCollateralUSD_LTV, uint256 totalDebtUSD) {
for (uint256 i = 0; i < marketList.length; i++) {
address asset = marketList[i];
UserPosition storage position = positions[user][asset];
MarketConfig storage config = marketConfigs[asset];
uint256 assetPrice = _getAssetPriceUSD(asset);
uint8 assetDecimals = config.decimals;
if (position.supplyShares > 0 && config.canBeCollateral) {
MarketState storage state = marketStates[asset];
uint256 supplyBalance = _convertToAssets(
position.supplyShares,
state.totalSupplyShares,
state.totalSupplyAssets
);
uint256 valueUSD = supplyBalance * assetPrice / (10 ** assetDecimals);
// 用 LTV 计算可借额度(比 liquidation threshold 低)
totalCollateralUSD_LTV += valueUSD * config.ltvBps / PERCENTAGE_FACTOR;
}
if (position.borrowBalance > 0) {
uint256 debtBalance = _getUserDebt(user, asset);
totalDebtUSD += debtBalance * assetPrice / (10 ** assetDecimals);
}
}
}
// ============ 内部辅助函数 ============
/// @notice 计算用户当前实际债务(含利息)
function _getUserDebt(address user, address asset) internal view returns (uint256) {
UserPosition storage position = positions[user][asset];
if (position.borrowBalance == 0) return 0;
MarketState storage state = marketStates[asset];
// 实际债务 = 记录金额 * (当前索引 / 用户索引)
return position.borrowBalance
* state.borrowIndex / position.userBorrowIndex;
}
/// @notice 获取资产 USD 价格(18 位精度)
function _getAssetPriceUSD(address asset) internal view returns (uint256) {
address feed = priceFeeds[asset];
require(feed != address(0), "No price feed");
AggregatorV3Interface priceFeed = AggregatorV3Interface(feed);
(
uint80 roundId,
int256 price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
// 五重安全检查
if (price <= 0) revert InvalidPrice();
if (block.timestamp - updatedAt > MAX_PRICE_AGE) revert StalePrice();
if (answeredInRound < roundId) revert StalePrice();
// 标准化到 18 位精度
uint8 feedDecimals = priceFeed.decimals();
if (feedDecimals < 18) {
return uint256(price) * 10 ** (18 - feedDecimals);
}
return uint256(price) / 10 ** (feedDecimals - 18);
}
// 借款列表管理
mapping(address => address[]) internal userBorrowAssets;
function _addToBorrowList(address user, address asset) internal {
address[] storage list = userBorrowAssets[user];
for (uint256 i = 0; i < list.length; i++) {
if (list[i] == asset) return; // 已存在
}
list.push(asset);
}
function _removeFromBorrowList(address user, address asset) internal {
address[] storage list = userBorrowAssets[user];
for (uint256 i = 0; i < list.length; i++) {
if (list[i] == asset) {
list[i] = list[list.length - 1];
list.pop();
return;
}
}
}
}
Foundry 测试
contract BorrowTest is Test {
// ... 使用 Day 75 的测试基础设施
function test_Borrow_BasicFlow() public {
// Alice 存入 10 ETH 作为抵押品
vm.prank(alice);
pool.supply(address(weth), 10 ether);
// Mock ETH 价格 = $2000
mockChainlink.setPrice(2000e8);
// Alice 借出 10,000 USDC(LTV 80%: 10 * 2000 * 0.8 = 16000 可借)
vm.prank(alice);
pool.borrow(address(usdc), 10_000e6);
// 验证
assertEq(usdc.balanceOf(alice), 10_000e6);
uint256 hf = pool.getHealthFactor(alice);
assertGt(hf, 1e18, "Health factor should be > 1");
}
function test_Borrow_ExceedsCapacity_Reverts() public {
vm.prank(alice);
pool.supply(address(weth), 10 ether);
mockChainlink.setPrice(2000e8);
// 尝试借超过 LTV 允许的金额
// 可借: 10 * 2000 * 0.8 = 16,000 USDC
vm.prank(alice);
vm.expectRevert(MiniLendingPool.HealthFactorBelowOne.selector);
pool.borrow(address(usdc), 17_000e6);
}
function test_Repay_FullRepay() public {
// 设置:Alice 存入抵押品并借款
vm.startPrank(alice);
pool.supply(address(weth), 10 ether);
pool.borrow(address(usdc), 10_000e6);
// 时间推进 30 天(累计利息)
vm.warp(block.timestamp + 30 days);
// 全额还款
usdc.approve(address(pool), type(uint256).max);
pool.repay(address(usdc), type(uint256).max);
vm.stopPrank();
// 验证无债务
uint256 hf = pool.getHealthFactor(alice);
assertEq(hf, type(uint256).max, "HF should be max (no debt)");
}
function test_HealthFactor_DropsBelowOne() public {
vm.prank(alice);
pool.supply(address(weth), 10 ether);
mockChainlink.setPrice(2000e8);
vm.prank(alice);
pool.borrow(address(usdc), 15_000e6);
// ETH 价格暴跌到 $1100
// 加权抵押: 10 * 1100 * 0.85 = $9,350
// 债务: $15,000
// HF = 9350 / 15000 = 0.623 < 1
mockChainlink.setPrice(1100e8);
uint256 hf = pool.getHealthFactor(alice);
assertLt(hf, 1e18, "HF should be < 1 after price drop");
}
function test_Interest_AccruesOnDebt() public {
vm.prank(alice);
pool.supply(address(weth), 100 ether);
vm.prank(bob);
pool.supply(address(usdc), 1_000_000e6);
mockChainlink.setPrice(2000e8);
vm.prank(alice);
pool.borrow(address(usdc), 100_000e6);
// 365 天后
vm.warp(block.timestamp + 365 days);
// 利息应该增加
uint256 debt = pool.getUserDebt(alice, address(usdc));
assertGt(debt, 100_000e6, "Debt should increase with interest");
}
}
关键要点总结
| 要点 | 说明 |
|---|---|
| 借款索引 | 全局索引避免逐用户更新利息,O(1) 获取实际债务 |
| LTV vs 清算阈值 | LTV 限制借入,清算阈值触发清算,两者之间是安全缓冲 |
| Chainlink 五重检查 | 正值、新鲜度、轮次完整性、roundId、精度标准化 |
| 健康因子 | HF < 1 可清算,HF > 1 安全,设计时注意缓冲区 |
| 清算级联 | 大规模清算可能引发死亡螺旋,需要机制缓解 |
常见误区
- "借款利率是固定的" — 利率随利用率实时变化,借款后利率可能改变
- "HF = 1 就安全" — HF = 1 意味着已经到清算边缘,应该保持 HF > 1.5 以上
- "只需要一个价格源" — 生产协议通常使用多个预言机源交叉验证
- "借款索引只影响单个用户" — 借款索引是全局的,所有借款人共享
- "Chainlink 永远可靠" — Chainlink 也可能出现价格延迟、心跳超时、L2 sequencer 宕机等情况
面试关联
面试题:如何设计一个安全的借贷协议预言机系统?
30 秒回答: 使用 Chainlink 作为主要价格源,加上新鲜度检查、价格有效性验证、精度标准化。对于关键操作(清算),应该使用多源验证。同时需要处理 Chainlink 宕机的降级方案。
2 分钟回答: 预言机是借贷协议最关键的外部依赖。首先选择 Chainlink 作为主要数据源,因为它有去中心化的节点网络和经济激励机制。集成时必须做五重安全检查:价格正值、数据新鲜度(不超过心跳周期)、轮次完整性、roundId 有效性、精度标准化。对于大型协议,建议使用多预言机聚合——Chainlink + TWAP + 备用源(如 Pyth),当主源偏差超过阈值时暂停清算操作。在 L2 场景下还需要检查 Sequencer 状态,因为 L2 sequencer 宕机期间 Chainlink 价格不会更新。最后,需要有紧急暂停机制,当所有价格源都不可用时,暂停借款和清算操作。
追问准备
- Q: Chainlink 价格更新延迟了怎么办? A: 设置合理的
MAX_PRICE_AGE,超时则暂停风险操作。同时监控偏差阈值触发告警。 - Q: LTV 和 Liquidation Threshold 为什么不同? A: 两者之间的差值(如 80% vs 85%)是安全缓冲区,给借款人时间补充抵押品或还款。
参考资源
| 资源 | 说明 |
|---|---|
| Chainlink Data Feeds | 官方文档 |
| Chainlink 价格 Feed 地址 | 各链地址列表 |
| Aave V3 健康因子 | Aave 清算文档 |
| Compound 借款索引 | Compound 利率模型 |
| L2 Sequencer Uptime Feed | L2 Sequencer 检查 |
| Euler Finance 攻击分析 | 预言机相关的安全事件 |