返回 SC 笔记
SC Day 75

Mini Lending — 存款池 + 利率模型实现

### 1. 份额制会计模型(Share-Based Accounting)

2026-06-24
第四阶段:综合实战 (73-80)
lendingdepositwithdrawinterest-rateerc4626share-accounting

日期: 2026-06-24 方向: Solidity 阶段: 第四阶段:综合实战 (73-80) 标签: #lending #deposit #withdraw #interest-rate #erc4626 #share-accounting


今日目标

类型内容
学习份额制会计模型(ERC4626)、利率累计机制、利用率-利率曲线
实操实现 MiniLendingPool 的 supply/withdraw 函数和 InterestRateModel
产出完整的存款池合约 + 利率模型合约 + 测试用例

核心概念

1. 份额制会计模型(Share-Based Accounting)

借贷协议中,存款人的余额会因利息而增长。有两种记录方式:

方式说明代表优点缺点
Rebase每个用户的余额直接增长aToken直观需要遍历或 rebase 事件
份额制用户持有份额,份额对应资产增长cToken/ERC4626Gas 效率高不够直观

我们选择份额制,类似 ERC4626 Vault 标准:

存款时:用户存入 1000 USDC,获得 1000 shares(假设 1:1)
时间过去,利息累计...
现在:1000 shares = 1050 USDC(因为池子里总资产增长了)

核心公式

存款获得的份额:
shares = depositAmount * totalShares / totalAssets

取款获得的资产:
assets = shareAmount * totalAssets / totalShares

汇率(每份额对应的资产):
exchangeRate = totalAssets / totalShares

初始状态处理

当池子为空(totalShares = 0)时,有一个经典问题——首个存款人攻击(Inflation Attack):

攻击步骤:
1. 攻击者存入 1 wei,获得 1 share
2. 攻击者直接转入 10000 USDC 到合约(不通过 deposit)
3. 现在 1 share = 10001 USDC
4. 受害者存入 9999 USDC
5. 受害者的 shares = 9999 * 1 / 10001 = 0 shares!(整数除法截断)
6. 攻击者取出 1 share = 19999 USDC(偷走受害者的 9999)

防护方案:
1. 初始存款时 mint 一些 shares 给零地址("dead shares")
2. 使用虚拟份额偏移(virtual shares/assets)

2. 利息累计机制

利息不是定时计算的,而是在每次操作前通过 _accrueInterest() 按时间累计:

Simple Interest(简单利息):
  newBorrows = totalBorrows * (1 + rate * timeElapsed / SECONDS_PER_YEAR)
  ※ 我们的实现使用简单利息(Gas 效率高)

Compound Interest(复利):
  newBorrows = totalBorrows * (1 + rate / n)^(n * timeElapsed)
  ※ Aave V3 使用精确的复利计算(更精确但 Gas 更高)

代码实战

InterestRateModel 合约

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

/// @title InterestRateModel - 分段线性利率模型
/// @notice 基于资金利用率计算借款和存款利率
/// @dev 利率曲线在 optimalUtilization 处有拐点
contract InterestRateModel {
    // ============ 常量 ============
    uint256 public constant PRECISION = 1e18;
    uint256 public constant SECONDS_PER_YEAR = 365 days;
    uint256 public constant MAX_RATE = 500e16; // 500% 年化上限

    // ============ 参数 ============
    uint256 public immutable baseRatePerSecond;
    uint256 public immutable slope1PerSecond;
    uint256 public immutable slope2PerSecond;
    uint256 public immutable optimalUtilization;

    /// @param _baseRatePerYear 基础年利率 (e.g., 2e16 = 2%)
    /// @param _slope1PerYear 拐点前年化斜率 (e.g., 4e16 = 4%)
    /// @param _slope2PerYear 拐点后年化斜率 (e.g., 300e16 = 300%)
    /// @param _optimalUtilization 最优利用率 (e.g., 80e16 = 80%)
    constructor(
        uint256 _baseRatePerYear,
        uint256 _slope1PerYear,
        uint256 _slope2PerYear,
        uint256 _optimalUtilization
    ) {
        require(_optimalUtilization > 0 && _optimalUtilization < PRECISION, "Invalid optimal");
        require(_baseRatePerYear + _slope1PerYear + _slope2PerYear <= MAX_RATE, "Rate too high");

        baseRatePerSecond = _baseRatePerYear / SECONDS_PER_YEAR;
        slope1PerSecond = _slope1PerYear / SECONDS_PER_YEAR;
        slope2PerSecond = _slope2PerYear / SECONDS_PER_YEAR;
        optimalUtilization = _optimalUtilization;
    }

    /// @notice 计算当前借款利率(每秒)
    /// @param utilization 当前利用率 (18 位精度, e.g., 80e16 = 80%)
    /// @return borrowRatePerSecond 每秒借款利率
    function getBorrowRate(uint256 utilization) external view returns (uint256) {
        if (utilization <= optimalUtilization) {
            // 拐点前: baseRate + utilization / optimal * slope1
            return baseRatePerSecond +
                (utilization * slope1PerSecond / optimalUtilization);
        } else {
            // 拐点后: baseRate + slope1 + excessUtil / (1 - optimal) * slope2
            uint256 excessUtilization = utilization - optimalUtilization;
            uint256 maxExcess = PRECISION - optimalUtilization;
            return baseRatePerSecond +
                slope1PerSecond +
                (excessUtilization * slope2PerSecond / maxExcess);
        }
    }

    /// @notice 计算当前存款利率(每秒)
    /// @param utilization 当前利用率
    /// @param reserveFactorBps 协议留存比例(基点)
    /// @return supplyRatePerSecond 每秒存款利率
    function getSupplyRate(
        uint256 utilization,
        uint16 reserveFactorBps
    ) external view returns (uint256) {
        uint256 borrowRate = this.getBorrowRate(utilization);
        // supplyRate = borrowRate * utilization * (1 - reserveFactor)
        return borrowRate * utilization / PRECISION
            * (10000 - reserveFactorBps) / 10000;
    }

    /// @notice 获取年化借款利率(展示用)
    function getBorrowRatePerYear(uint256 utilization) external view returns (uint256) {
        return this.getBorrowRate(utilization) * SECONDS_PER_YEAR;
    }

    /// @notice 获取年化存款利率(展示用)
    function getSupplyRatePerYear(
        uint256 utilization,
        uint16 reserveFactorBps
    ) external view returns (uint256) {
        return this.getSupplyRate(utilization, reserveFactorBps) * SECONDS_PER_YEAR;
    }
}

MiniLendingPool — Supply/Withdraw 实现

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/// @title MiniLendingPool - 存款和取款功能
/// @notice Mini Lending 协议核心合约 - Day 75 版本(存款/取款)
contract MiniLendingPool is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    // ============ 常量 ============
    uint256 public constant PRECISION = 1e18;
    uint256 public constant PERCENTAGE_FACTOR = 10000;
    uint256 public constant SECONDS_PER_YEAR = 365 days;

    /// @dev 虚拟份额/资产偏移,防止首个存款人攻击
    uint256 public constant VIRTUAL_SHARES = 1e6;
    uint256 public constant VIRTUAL_ASSETS = 1e6;

    // ============ 结构体 ============
    struct MarketConfig {
        bool isListed;
        bool canBeCollateral;
        bool canBeBorrowed;
        uint16 ltvBps;
        uint16 liquidationThresholdBps;
        uint16 liquidationBonusBps;
        uint16 reserveFactorBps;
        uint8 decimals;
    }

    struct MarketState {
        uint256 totalSupplyShares;
        uint256 totalSupplyAssets;
        uint256 totalBorrows;
        uint256 borrowIndex;
        uint40 lastUpdateTimestamp;
        uint256 reserveBalance;
    }

    struct UserPosition {
        uint256 supplyShares;
        uint256 borrowBalance;     // 标准化借款余额
        uint256 userBorrowIndex;   // 用户最后交互时的 borrowIndex
    }

    // ============ 状态变量 ============
    mapping(address => MarketConfig) public marketConfigs;
    mapping(address => MarketState) public marketStates;
    mapping(address => InterestRateModel) public interestModels;
    mapping(address => mapping(address => UserPosition)) public positions;

    // ============ 事件 ============
    event Supply(
        address indexed user,
        address indexed asset,
        uint256 amount,
        uint256 shares
    );
    event Withdraw(
        address indexed user,
        address indexed asset,
        uint256 amount,
        uint256 shares
    );
    event InterestAccrued(
        address indexed asset,
        uint256 interestAccumulated,
        uint256 newBorrowIndex,
        uint256 newTotalBorrows
    );

    // ============ 错误 ============
    error MarketNotListed();
    error ZeroAmount();
    error InsufficientShares();
    error InsufficientLiquidity();

    // ============ 修饰符 ============
    modifier onlyListedMarket(address asset) {
        if (!marketConfigs[asset].isListed) revert MarketNotListed();
        _;
    }

    // ============ 核心函数:存款 ============

    /// @notice 存入资产到借贷池
    /// @param asset 资产地址
    /// @param amount 存入数量
    /// @return shares 获得的份额
    function supply(
        address asset,
        uint256 amount
    ) external nonReentrant onlyListedMarket(asset) returns (uint256 shares) {
        if (amount == 0) revert ZeroAmount();

        // 步骤 1:累计利息
        _accrueInterest(asset);

        MarketState storage state = marketStates[asset];

        // 步骤 2:计算份额(使用虚拟偏移防止通胀攻击)
        shares = _convertToShares(
            amount,
            state.totalSupplyShares,
            state.totalSupplyAssets
        );

        // 步骤 3:更新状态
        state.totalSupplyShares += shares;
        state.totalSupplyAssets += amount;
        positions[msg.sender][asset].supplyShares += shares;

        // 步骤 4:转入资产
        IERC20(asset).safeTransferFrom(msg.sender, address(this), amount);

        emit Supply(msg.sender, asset, amount, shares);
    }

    // ============ 核心函数:取款 ============

    /// @notice 从借贷池取出资产
    /// @param asset 资产地址
    /// @param shares 取出的份额数量(传 type(uint256).max 表示全部取出)
    /// @return amount 实际取出的资产数量
    function withdraw(
        address asset,
        uint256 shares
    ) external nonReentrant onlyListedMarket(asset) returns (uint256 amount) {
        // 步骤 1:累计利息
        _accrueInterest(asset);

        MarketState storage state = marketStates[asset];
        UserPosition storage position = positions[msg.sender][asset];

        // 处理"全部取出"
        if (shares == type(uint256).max) {
            shares = position.supplyShares;
        }

        if (shares == 0) revert ZeroAmount();
        if (shares > position.supplyShares) revert InsufficientShares();

        // 步骤 2:计算对应的资产数量
        amount = _convertToAssets(
            shares,
            state.totalSupplyShares,
            state.totalSupplyAssets
        );

        // 步骤 3:检查流动性
        uint256 availableLiquidity = state.totalSupplyAssets - state.totalBorrows;
        if (amount > availableLiquidity) revert InsufficientLiquidity();

        // 步骤 4:更新状态
        state.totalSupplyShares -= shares;
        state.totalSupplyAssets -= amount;
        position.supplyShares -= shares;

        // 步骤 5:转出资产
        IERC20(asset).safeTransfer(msg.sender, amount);

        // 步骤 6:检查健康因子(如果用户有借款)
        // _checkHealthFactor(msg.sender); // Day 77 实现

        emit Withdraw(msg.sender, asset, amount, shares);
    }

    // ============ 按资产数量取款的便捷函数 ============

    /// @notice 按资产数量取款(用户友好版本)
    /// @param asset 资产地址
    /// @param amount 希望取出的资产数量
    function withdrawAssets(
        address asset,
        uint256 amount
    ) external nonReentrant onlyListedMarket(asset) returns (uint256 shares) {
        if (amount == 0) revert ZeroAmount();

        _accrueInterest(asset);

        MarketState storage state = marketStates[asset];
        UserPosition storage position = positions[msg.sender][asset];

        // 计算需要的份额(向上取整,保护协议)
        shares = _convertToSharesRoundUp(
            amount,
            state.totalSupplyShares,
            state.totalSupplyAssets
        );

        if (shares > position.supplyShares) revert InsufficientShares();

        uint256 availableLiquidity = state.totalSupplyAssets - state.totalBorrows;
        if (amount > availableLiquidity) revert InsufficientLiquidity();

        state.totalSupplyShares -= shares;
        state.totalSupplyAssets -= amount;
        position.supplyShares -= shares;

        IERC20(asset).safeTransfer(msg.sender, amount);

        emit Withdraw(msg.sender, asset, amount, shares);
    }

    // ============ 利息累计 ============

    /// @notice 更新市场利息状态
    function _accrueInterest(address asset) internal {
        MarketState storage state = marketStates[asset];

        uint256 timeElapsed = block.timestamp - state.lastUpdateTimestamp;
        if (timeElapsed == 0) return;
        if (state.totalBorrows == 0) {
            state.lastUpdateTimestamp = uint40(block.timestamp);
            return;
        }

        // 计算利用率
        uint256 utilization = state.totalBorrows * PRECISION / state.totalSupplyAssets;

        // 从利率模型获取每秒借款利率
        InterestRateModel model = interestModels[asset];
        uint256 borrowRatePerSecond = model.getBorrowRate(utilization);

        // 计算累计利息(简单利息)
        uint256 interestFactor = borrowRatePerSecond * timeElapsed;
        uint256 interestAccumulated = state.totalBorrows * interestFactor / PRECISION;

        // 更新总借款
        state.totalBorrows += interestAccumulated;

        // 更新借款索引
        state.borrowIndex += state.borrowIndex * interestFactor / PRECISION;

        // 协议留存
        uint16 reserveFactor = marketConfigs[asset].reserveFactorBps;
        uint256 reserveShare = interestAccumulated * reserveFactor / PERCENTAGE_FACTOR;
        state.reserveBalance += reserveShare;

        // 存款人获得的利息 = 总利息 - 协议留存
        state.totalSupplyAssets += (interestAccumulated - reserveShare);

        state.lastUpdateTimestamp = uint40(block.timestamp);

        emit InterestAccrued(
            asset,
            interestAccumulated,
            state.borrowIndex,
            state.totalBorrows
        );
    }

    // ============ 份额转换函数 ============

    /// @dev 资产 → 份额(向下取整,存款时使用)
    function _convertToShares(
        uint256 assets,
        uint256 totalShares,
        uint256 totalAssets
    ) internal pure returns (uint256) {
        return assets * (totalShares + VIRTUAL_SHARES) / (totalAssets + VIRTUAL_ASSETS);
    }

    /// @dev 份额 → 资产(向下取整,取款时使用)
    function _convertToAssets(
        uint256 shares,
        uint256 totalShares,
        uint256 totalAssets
    ) internal pure returns (uint256) {
        return shares * (totalAssets + VIRTUAL_ASSETS) / (totalShares + VIRTUAL_SHARES);
    }

    /// @dev 资产 → 份额(向上取整,按资产取款时使用)
    function _convertToSharesRoundUp(
        uint256 assets,
        uint256 totalShares,
        uint256 totalAssets
    ) internal pure returns (uint256) {
        uint256 numerator = assets * (totalShares + VIRTUAL_SHARES);
        uint256 denominator = totalAssets + VIRTUAL_ASSETS;
        return (numerator + denominator - 1) / denominator; // 向上取整
    }

    // ============ 视图函数 ============

    /// @notice 获取用户在某个市场的存款资产数量
    function getUserSupplyBalance(
        address user,
        address asset
    ) external view returns (uint256) {
        MarketState storage state = marketStates[asset];
        uint256 shares = positions[user][asset].supplyShares;
        if (shares == 0) return 0;
        return _convertToAssets(shares, state.totalSupplyShares, state.totalSupplyAssets);
    }

    /// @notice 获取当前汇率(每份额对应的资产数量)
    function getExchangeRate(address asset) external view returns (uint256) {
        MarketState storage state = marketStates[asset];
        if (state.totalSupplyShares == 0) return PRECISION;
        return (state.totalSupplyAssets + VIRTUAL_ASSETS) * PRECISION
            / (state.totalSupplyShares + VIRTUAL_SHARES);
    }

    /// @notice 获取当前利用率
    function getUtilization(address asset) external view returns (uint256) {
        MarketState storage state = marketStates[asset];
        if (state.totalSupplyAssets == 0) return 0;
        return state.totalBorrows * PRECISION / state.totalSupplyAssets;
    }
}

Foundry 测试

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

import "forge-std/Test.sol";
import "../src/MiniLendingPool.sol";
import "../src/InterestRateModel.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

// 测试用 ERC20
contract MockERC20 is ERC20 {
    constructor() ERC20("Mock USDC", "USDC") {}
    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
    function decimals() public pure override returns (uint8) { return 6; }
}

contract MiniLendingPoolTest is Test {
    MiniLendingPool public pool;
    InterestRateModel public rateModel;
    MockERC20 public usdc;

    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");

    function setUp() public {
        pool = new MiniLendingPool();
        usdc = new MockERC20();

        // 利率模型:base 2%, slope1 4%, slope2 300%, optimal 80%
        rateModel = new InterestRateModel(
            2e16,   // 2% base
            4e16,   // 4% slope1
            300e16, // 300% slope2
            80e16   // 80% optimal
        );

        // 添加市场(省略管理函数具体实现)
        // pool.addMarket(address(usdc), ..., rateModel);

        // 给测试用户分配代币
        usdc.mint(alice, 1_000_000e6);
        usdc.mint(bob, 1_000_000e6);

        // 授权
        vm.prank(alice);
        usdc.approve(address(pool), type(uint256).max);
        vm.prank(bob);
        usdc.approve(address(pool), type(uint256).max);
    }

    function test_Supply_BasicDeposit() public {
        uint256 depositAmount = 10_000e6; // 10,000 USDC

        vm.prank(alice);
        uint256 shares = pool.supply(address(usdc), depositAmount);

        assertGt(shares, 0, "Should receive shares");
        assertEq(
            usdc.balanceOf(address(pool)),
            depositAmount,
            "Pool should hold deposited USDC"
        );
    }

    function test_Supply_MultipleDepositors() public {
        // Alice 先存
        vm.prank(alice);
        uint256 aliceShares = pool.supply(address(usdc), 10_000e6);

        // Bob 后存
        vm.prank(bob);
        uint256 bobShares = pool.supply(address(usdc), 10_000e6);

        // 相同金额应获得相同份额(无利息累计时)
        assertEq(aliceShares, bobShares, "Same amount should get same shares");
    }

    function test_Withdraw_FullWithdraw() public {
        uint256 depositAmount = 10_000e6;

        vm.prank(alice);
        uint256 shares = pool.supply(address(usdc), depositAmount);

        uint256 balanceBefore = usdc.balanceOf(alice);

        vm.prank(alice);
        uint256 withdrawnAmount = pool.withdraw(address(usdc), shares);

        uint256 balanceAfter = usdc.balanceOf(alice);
        assertEq(balanceAfter - balanceBefore, depositAmount, "Should withdraw full amount");
    }

    function test_Interest_AccruesOverTime() public {
        // Alice 存款
        vm.prank(alice);
        pool.supply(address(usdc), 100_000e6);

        // 模拟借款(通过直接修改状态,实际 borrow 功能 Day 77 实现)
        // 假设有人借了 60,000 USDC (60% 利用率)

        // 时间快进 365 天
        vm.warp(block.timestamp + 365 days);

        // 触发利息累计
        vm.prank(alice);
        uint256 amount = pool.withdraw(address(usdc), type(uint256).max);

        // Alice 应该获得本金 + 利息
        assertGt(amount, 100_000e6, "Should earn interest");
    }

    function test_Supply_ZeroAmount_Reverts() public {
        vm.prank(alice);
        vm.expectRevert(MiniLendingPool.ZeroAmount.selector);
        pool.supply(address(usdc), 0);
    }

    function test_Withdraw_InsufficientShares_Reverts() public {
        vm.prank(alice);
        pool.supply(address(usdc), 10_000e6);

        vm.prank(bob); // Bob 没有存款
        vm.expectRevert(MiniLendingPool.InsufficientShares.selector);
        pool.withdraw(address(usdc), 1);
    }

    function test_InterestRateModel_BelowOptimal() public {
        // 60% 利用率(低于 80% 最优点)
        uint256 utilization = 60e16;
        uint256 borrowRate = rateModel.getBorrowRatePerYear(utilization);

        // 预期: 2% + (60/80) * 4% = 2% + 3% = 5%
        uint256 expectedRate = 5e16;
        assertApproxEqRel(borrowRate, expectedRate, 1e15, "Rate should be ~5%");
    }

    function test_InterestRateModel_AboveOptimal() public {
        // 90% 利用率(高于 80% 最优点)
        uint256 utilization = 90e16;
        uint256 borrowRate = rateModel.getBorrowRatePerYear(utilization);

        // 预期: 2% + 4% + (10/20) * 300% = 2% + 4% + 150% = 156%
        uint256 expectedRate = 156e16;
        assertApproxEqRel(borrowRate, expectedRate, 1e15, "Rate should be ~156%");
    }

    /// @notice Fuzz 测试:任何存款金额都不应导致溢出或损失
    function testFuzz_Supply_NoLoss(uint256 amount) public {
        amount = bound(amount, 1, 1_000_000_000e6); // 1 wei 到 10 亿 USDC
        usdc.mint(alice, amount);

        vm.startPrank(alice);
        usdc.approve(address(pool), amount);
        uint256 shares = pool.supply(address(usdc), amount);

        uint256 withdrawnAmount = pool.withdraw(address(usdc), shares);
        vm.stopPrank();

        // 取出金额应该等于或略少于存入金额(取整误差)
        assertLe(withdrawnAmount, amount, "Should not withdraw more than deposited");
        assertGe(
            withdrawnAmount,
            amount - 1, // 最多损失 1 wei(取整误差)
            "Should not lose more than 1 wei"
        );
    }
}

关键要点总结

要点说明
份额制 > 直接记账Gas 效率高,无需遍历更新每个用户余额
虚拟偏移防通胀攻击VIRTUAL_SHARES/VIRTUAL_ASSETS 保护首个存款人
存款向下取整保护协议(用户少得份额)
取款向下取整保护协议(用户少得资产)
利息惰性累计不需要定时任务,每次操作前自动更新
利率拐点机制slope2 >> slope1,高利用率时急剧增加成本

常见误区

  1. "份额和资产始终 1:1" — 只在初始状态如此,利息累计后比例会变化
  2. "不用担心首个存款人攻击" — ERC4626 的经典漏洞,必须用虚拟偏移防护
  3. "利率是固定的" — 利率随利用率实时变化,每次操作都会重新计算
  4. "简单利息和复利没区别" — 在高利率和长时间段下差异显著,但简单利息 Gas 更低
  5. "Reserve Factor 只是协议收入" — 它也是存款人利率低于借款人利率的原因

面试关联

面试题:ERC4626 的通胀攻击是什么?如何防护?

30 秒回答: 通胀攻击是首个存款人通过直接转入大量资产膨胀汇率,使后续存款人因整数除法截断而损失资金。防护方式是使用虚拟份额/资产偏移,或者初始 mint 给零地址一些最小份额。

2 分钟回答: ERC4626 通胀攻击利用了份额计算中整数除法的精度丢失。攻击者首先存入极小金额获得 1 份份额,然后直接向合约转入大量代币(绕过 deposit),使每份额价值极高。当下一个用户存入时,因为份额公式中分母极大,整数除法结果为 0,用户损失全部存款。防护有两种主流方案:一是 OpenZeppelin 的虚拟偏移方法,在计算中加入虚拟的份额和资产(如各加 1e6),使攻击成本大幅增加;二是协议初始化时 mint 一些最小份额到零地址,确保 totalShares 不会太小。Morpho 和 Yearn V3 都采用了虚拟偏移方案。


参考资源

资源说明
ERC4626 标准代币化金库标准
OpenZeppelin ERC4626OZ 实现
Compound V2 利率模型利率曲线提案
Aave V3 利率策略Aave 利率文档
通胀攻击详解OZ 分析文章
Morpho 的份额实现生产级实现参考