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/ERC4626 | Gas 效率高 | 不够直观 |
我们选择份额制,类似 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" — 只在初始状态如此,利息累计后比例会变化
- "不用担心首个存款人攻击" — ERC4626 的经典漏洞,必须用虚拟偏移防护
- "利率是固定的" — 利率随利用率实时变化,每次操作都会重新计算
- "简单利息和复利没区别" — 在高利率和长时间段下差异显著,但简单利息 Gas 更低
- "Reserve Factor 只是协议收入" — 它也是存款人利率低于借款人利率的原因
面试关联
面试题:ERC4626 的通胀攻击是什么?如何防护?
30 秒回答: 通胀攻击是首个存款人通过直接转入大量资产膨胀汇率,使后续存款人因整数除法截断而损失资金。防护方式是使用虚拟份额/资产偏移,或者初始 mint 给零地址一些最小份额。
2 分钟回答: ERC4626 通胀攻击利用了份额计算中整数除法的精度丢失。攻击者首先存入极小金额获得 1 份份额,然后直接向合约转入大量代币(绕过 deposit),使每份额价值极高。当下一个用户存入时,因为份额公式中分母极大,整数除法结果为 0,用户损失全部存款。防护有两种主流方案:一是 OpenZeppelin 的虚拟偏移方法,在计算中加入虚拟的份额和资产(如各加 1e6),使攻击成本大幅增加;二是协议初始化时 mint 一些最小份额到零地址,确保 totalShares 不会太小。Morpho 和 Yearn V3 都采用了虚拟偏移方案。
参考资源
| 资源 | 说明 |
|---|---|
| ERC4626 标准 | 代币化金库标准 |
| OpenZeppelin ERC4626 | OZ 实现 |
| Compound V2 利率模型 | 利率曲线提案 |
| Aave V3 利率策略 | Aave 利率文档 |
| 通胀攻击详解 | OZ 分析文章 |
| Morpho 的份额实现 | 生产级实现参考 |