返回 SC 笔记
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)
链下数据源 (多个独立 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);
}
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 安全,设计时注意缓冲区
清算级联大规模清算可能引发死亡螺旋,需要机制缓解

常见误区

  1. "借款利率是固定的" — 利率随利用率实时变化,借款后利率可能改变
  2. "HF = 1 就安全" — HF = 1 意味着已经到清算边缘,应该保持 HF > 1.5 以上
  3. "只需要一个价格源" — 生产协议通常使用多个预言机源交叉验证
  4. "借款索引只影响单个用户" — 借款索引是全局的,所有借款人共享
  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 FeedL2 Sequencer 检查
Euler Finance 攻击分析预言机相关的安全事件