SC Day 32
Solidity - Vault 合约 (ERC4626) - deposit/withdraw/shares 计算
### 一、ERC4626 是什么?为什么需要它?
2026-05-02
第二阶段:框架实战SolidityERC4626VaultDeFiShareCalculation
日期: 2026-05-02 方向: Solidity 阶段: 第二阶段:框架实战 标签: #Solidity #ERC4626 #Vault #DeFi #ShareCalculation
今日目标
- 深入理解 ERC4626 Tokenized Vault Standard 的设计动机和核心接口
- 掌握 shares 与 assets 之间的转换数学(deposit/withdraw/mint/redeem)
- 实现一个完整的 SimpleVault 合约,包含 share 计算、rounding 处理、边界情况
- 理解 ERC4626 为什么对 DeFi 可组合性至关重要
核心概念
一、ERC4626 是什么?为什么需要它?
问题背景
在 ERC4626 出现之前,每个 DeFi 协议实现 Vault(金库/资金池)的方式都不同:
| 协议 | Vault 实现方式 |
|---|---|
| Yearn V2 | yToken,自定义 pricePerShare |
| Compound | cToken,exchangeRate 计算 |
| Aave | aToken,rebasing 模型 |
| Sushiswap | xSUSHI,简单比例计算 |
这导致了几个严重问题:
- 集成成本高:每次集成新协议都要写适配器
- 安全审计难:每种实现都有独特的漏洞风险面
- 可组合性差:无法构建通用的收益聚合器
ERC4626 的解决方案
ERC4626 定义了一个标准化的带收益 Vault 接口,所有 Vault 都遵循同样的 API:
用户 ──deposit(assets)──→ Vault ──→ 返回 shares
用户 ←──withdraw(assets)── Vault ←── 销毁 shares
用户 ──mint(shares)──→ Vault ──→ 扣除 assets
用户 ←──redeem(shares)── Vault ←── 返还 assets
二、核心概念:Assets 与 Shares
ERC4626 的核心是两个概念之间的转换:
- Assets(资产):底层代币(如 USDC、DAI、WETH)
- Shares(份额):Vault 发行的代表权益的代币
转换公式
shares = assets × totalShares / totalAssets
assets = shares × totalAssets / totalShares
用一个直观的例子说明:
初始状态:
totalAssets = 0, totalShares = 0
Alice 存入 1000 USDC:
shares = 1000(首次存入 1:1)
totalAssets = 1000, totalShares = 1000
Vault 产生了 100 USDC 收益:
totalAssets = 1100, totalShares = 1000
每份 share 的价值 = 1100 / 1000 = 1.1 USDC
Bob 存入 1100 USDC:
shares = 1100 × 1000 / 1100 = 1000 shares
totalAssets = 2200, totalShares = 2000
Alice 赎回她的 1000 shares:
assets = 1000 × 2200 / 2000 = 1100 USDC
Alice 获利 100 USDC ✓
三、ERC4626 完整接口
interface IERC4626 is IERC20 {
// ===== 元数据 =====
function asset() external view returns (address);
// ===== 存入(以 assets 为基准)=====
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function previewDeposit(uint256 assets) external view returns (uint256 shares);
function maxDeposit(address receiver) external view returns (uint256 maxAssets);
// ===== 铸造(以 shares 为基准)=====
function mint(uint256 shares, address receiver) external returns (uint256 assets);
function previewMint(uint256 shares) external view returns (uint256 assets);
function maxMint(address receiver) external view returns (uint256 maxShares);
// ===== 取出(以 assets 为基准)=====
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
function previewWithdraw(uint256 assets) external view returns (uint256 shares);
function maxWithdraw(address owner) external view returns (uint256 maxAssets);
// ===== 赎回(以 shares 为基准)=====
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
function previewRedeem(uint256 shares) external view returns (uint256 assets);
function maxRedeem(address owner) external view returns (uint256 maxShares);
// ===== 转换函数 =====
function totalAssets() external view returns (uint256);
function convertToShares(uint256 assets) external view returns (uint256 shares);
function convertToAssets(uint256 shares) external view returns (uint256 assets);
// ===== 事件 =====
event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares);
event Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares);
}
四个核心操作的对比:
| 操作 | 输入 | 输出 | 场景 |
|---|---|---|---|
| deposit | 指定 assets 数量 | 获得 shares | "我要存 1000 USDC" |
| mint | 指定 shares 数量 | 扣除 assets | "我要获得 100 份额" |
| withdraw | 指定 assets 数量 | 销毁 shares | "我要取 1000 USDC" |
| redeem | 指定 shares 数量 | 获得 assets | "我要赎回 100 份额" |
四、Rounding(舍入)规则
ERC4626 规范中有明确的舍入要求,原则是保护 Vault(而非用户):
| 操作 | 舍入方向 | 原因 |
|---|---|---|
| deposit → shares | 向下取整 | 用户获得更少 shares,Vault 受益 |
| mint → assets | 向上取整 | 用户付出更多 assets,Vault 受益 |
| withdraw → shares | 向上取整 | 用户销毁更多 shares,Vault 受益 |
| redeem → assets | 向下取整 | 用户获得更少 assets,Vault 受益 |
代码实战
完整的 SimpleVault 合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/**
* @title SimpleVault
* @notice ERC4626 兼容的简化 Vault 实现
* @dev 演示 shares 计算和 rounding 处理
*/
contract SimpleVault is ERC20, ReentrancyGuard {
using SafeERC20 for IERC20;
using Math for uint256;
IERC20 public immutable asset;
uint8 private immutable _underlyingDecimals;
// ===== Events =====
event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares);
event Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares);
// ===== Errors =====
error ERC4626ExceededMaxDeposit(address receiver, uint256 assets, uint256 max);
error ERC4626ExceededMaxMint(address receiver, uint256 shares, uint256 max);
error ERC4626ExceededMaxWithdraw(address owner, uint256 assets, uint256 max);
error ERC4626ExceededMaxRedeem(address owner, uint256 shares, uint256 max);
error ZeroShares();
error ZeroAssets();
constructor(
IERC20 _asset,
string memory _name,
string memory _symbol
) ERC20(_name, _symbol) {
asset = _asset;
_underlyingDecimals = 18; // 简化处理
}
// ===== 核心:Assets ↔ Shares 转换 =====
/**
* @notice 计算 Vault 中的总资产
*/
function totalAssets() public view returns (uint256) {
return asset.balanceOf(address(this));
}
/**
* @notice 将 assets 转换为 shares
* @param assets 资产数量
* @param rounding 舍入方向
*/
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view returns (uint256) {
uint256 supply = totalSupply();
// 如果 supply 为 0 或 totalAssets 为 0,则 1:1 兑换
return (supply == 0 || totalAssets() == 0)
? assets
: assets.mulDiv(supply, totalAssets(), rounding);
}
/**
* @notice 将 shares 转换为 assets
* @param shares 份额数量
* @param rounding 舍入方向
*/
function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view returns (uint256) {
uint256 supply = totalSupply();
return (supply == 0)
? shares
: shares.mulDiv(totalAssets(), supply, rounding);
}
// 公开的只读转换函数(向下取整,保守估计)
function convertToShares(uint256 assets) public view returns (uint256) {
return _convertToShares(assets, Math.Rounding.Floor);
}
function convertToAssets(uint256 shares) public view returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Floor);
}
// ===== Deposit: 指定 assets 数量 =====
function maxDeposit(address) public pure returns (uint256) {
return type(uint256).max;
}
function previewDeposit(uint256 assets) public view returns (uint256) {
return _convertToShares(assets, Math.Rounding.Floor); // 向下取整
}
/**
* @notice 存入指定数量的 assets,获得 shares
* @param assets 要存入的资产数量
* @param receiver shares 的接收者
* @return shares 获得的份额数量
*/
function deposit(uint256 assets, address receiver) public nonReentrant returns (uint256 shares) {
uint256 maxAssets = maxDeposit(receiver);
if (assets > maxAssets) {
revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets);
}
shares = previewDeposit(assets);
if (shares == 0) revert ZeroShares();
// 先转入 assets,再铸造 shares(CEI 模式)
asset.safeTransferFrom(msg.sender, address(this), assets);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
}
// ===== Mint: 指定 shares 数量 =====
function maxMint(address) public pure returns (uint256) {
return type(uint256).max;
}
function previewMint(uint256 shares) public view returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Ceil); // 向上取整
}
/**
* @notice 铸造指定数量的 shares,扣除相应 assets
* @param shares 要获得的份额数量
* @param receiver shares 的接收者
* @return assets 扣除的资产数量
*/
function mint(uint256 shares, address receiver) public nonReentrant returns (uint256 assets) {
uint256 maxShares = maxMint(receiver);
if (shares > maxShares) {
revert ERC4626ExceededMaxMint(receiver, shares, maxShares);
}
assets = previewMint(shares);
if (assets == 0) revert ZeroAssets();
asset.safeTransferFrom(msg.sender, address(this), assets);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
}
// ===== Withdraw: 指定 assets 数量 =====
function maxWithdraw(address owner) public view returns (uint256) {
return _convertToAssets(balanceOf(owner), Math.Rounding.Floor);
}
function previewWithdraw(uint256 assets) public view returns (uint256) {
return _convertToShares(assets, Math.Rounding.Ceil); // 向上取整
}
/**
* @notice 取出指定数量的 assets,销毁相应 shares
* @param assets 要取出的资产数量
* @param receiver assets 的接收者
* @param owner shares 的拥有者
* @return shares 销毁的份额数量
*/
function withdraw(uint256 assets, address receiver, address owner) public nonReentrant returns (uint256 shares) {
uint256 maxAssets = maxWithdraw(owner);
if (assets > maxAssets) {
revert ERC4626ExceededMaxWithdraw(owner, assets, maxAssets);
}
shares = previewWithdraw(assets);
// 处理 allowance(如果调用者不是 owner)
if (msg.sender != owner) {
_spendAllowance(owner, msg.sender, shares);
}
_burn(owner, shares);
asset.safeTransfer(receiver, assets);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
// ===== Redeem: 指定 shares 数量 =====
function maxRedeem(address owner) public view returns (uint256) {
return balanceOf(owner);
}
function previewRedeem(uint256 shares) public view returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Floor); // 向下取整
}
/**
* @notice 赎回指定数量的 shares,获得相应 assets
* @param shares 要赎回的份额数量
* @param receiver assets 的接收者
* @param owner shares 的拥有者
* @return assets 获得的资产数量
*/
function redeem(uint256 shares, address receiver, address owner) public nonReentrant returns (uint256 assets) {
uint256 maxShares = maxRedeem(owner);
if (shares > maxShares) {
revert ERC4626ExceededMaxRedeem(owner, shares, maxShares);
}
assets = previewRedeem(shares);
if (assets == 0) revert ZeroAssets();
if (msg.sender != owner) {
_spendAllowance(owner, msg.sender, shares);
}
_burn(owner, shares);
asset.safeTransfer(receiver, assets);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
}
完整的测试合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/SimpleVault.sol";
contract MockUSDC is ERC20 {
constructor() ERC20("USD Coin", "USDC") {
_mint(msg.sender, 1_000_000e18);
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract SimpleVaultTest is Test {
SimpleVault public vault;
MockUSDC public usdc;
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
address public charlie = makeAddr("charlie");
function setUp() public {
usdc = new MockUSDC();
vault = new SimpleVault(IERC20(address(usdc)), "Vault USDC", "vUSDC");
// 分配 USDC
usdc.transfer(alice, 100_000e18);
usdc.transfer(bob, 100_000e18);
usdc.transfer(charlie, 100_000e18);
}
// ===== 基础存取测试 =====
function test_FirstDeposit_OneToOne() public {
vm.startPrank(alice);
usdc.approve(address(vault), 1000e18);
uint256 shares = vault.deposit(1000e18, alice);
vm.stopPrank();
assertEq(shares, 1000e18, "First deposit should be 1:1");
assertEq(vault.balanceOf(alice), 1000e18);
assertEq(vault.totalAssets(), 1000e18);
}
function test_SecondDeposit_WithYield() public {
// Alice 存入 1000
vm.startPrank(alice);
usdc.approve(address(vault), 1000e18);
vault.deposit(1000e18, alice);
vm.stopPrank();
// 模拟收益:直接向 Vault 转入 500 USDC
usdc.mint(address(vault), 500e18);
// 现在 totalAssets = 1500, totalShares = 1000
// Bob 存入 1500
vm.startPrank(bob);
usdc.approve(address(vault), 1500e18);
uint256 bobShares = vault.deposit(1500e18, bob);
vm.stopPrank();
// Bob 应该得到 1500 * 1000 / 1500 = 1000 shares
assertEq(bobShares, 1000e18, "Bob should get proportional shares");
}
function test_Redeem_WithYield() public {
// Alice 存入 1000
vm.startPrank(alice);
usdc.approve(address(vault), 1000e18);
vault.deposit(1000e18, alice);
vm.stopPrank();
// 模拟 100% 收益
usdc.mint(address(vault), 1000e18);
// totalAssets = 2000, totalShares = 1000
// Alice 赎回所有 shares
vm.startPrank(alice);
uint256 assetsReceived = vault.redeem(1000e18, alice, alice);
vm.stopPrank();
// Alice 应该得到 1000 * 2000 / 1000 = 2000
assertEq(assetsReceived, 2000e18, "Alice should get double");
}
function test_Withdraw_ExactAmount() public {
vm.startPrank(alice);
usdc.approve(address(vault), 1000e18);
vault.deposit(1000e18, alice);
vm.stopPrank();
usdc.mint(address(vault), 1000e18);
// totalAssets = 2000, totalShares = 1000
// Alice 想取出恰好 500 USDC
vm.startPrank(alice);
uint256 sharesBurned = vault.withdraw(500e18, alice, alice);
vm.stopPrank();
// 需要销毁 500 * 1000 / 2000 = 250 shares
assertEq(sharesBurned, 250e18);
assertEq(vault.balanceOf(alice), 750e18);
}
// ===== 边界情况测试 =====
function test_RevertWhen_DepositGivesZeroShares() public {
// 先存大量制造大的 share 价格
vm.startPrank(alice);
usdc.approve(address(vault), 1000e18);
vault.deposit(1000e18, alice);
vm.stopPrank();
// 巨额收益让每个 share 很值钱
usdc.mint(address(vault), 999_000e18);
// totalAssets = 1_000_000, totalShares = 1000
// 1 share = 1000 USDC
// 存 999 wei 会得到 0 shares
vm.startPrank(bob);
usdc.approve(address(vault), 999);
vm.expectRevert(SimpleVault.ZeroShares.selector);
vault.deposit(999, bob);
vm.stopPrank();
}
function test_MultipleUsersCorrectDistribution() public {
// Alice 存入 1000
vm.startPrank(alice);
usdc.approve(address(vault), 1000e18);
vault.deposit(1000e18, alice);
vm.stopPrank();
// Bob 存入 2000
vm.startPrank(bob);
usdc.approve(address(vault), 2000e18);
vault.deposit(2000e18, bob);
vm.stopPrank();
// 300 USDC 收益
usdc.mint(address(vault), 300e18);
// totalAssets = 3300, totalShares = 3000
// Alice: 1000 shares → 1000 * 3300 / 3000 = 1100 USDC
// Bob: 2000 shares → 2000 * 3300 / 3000 = 2200 USDC
assertEq(vault.previewRedeem(1000e18), 1100e18);
assertEq(vault.previewRedeem(2000e18), 2200e18);
}
// ===== Fuzz Test =====
function testFuzz_DepositAndRedeem(uint256 amount) public {
vm.assume(amount > 1e6 && amount <= 100_000e18);
vm.startPrank(alice);
usdc.approve(address(vault), amount);
uint256 shares = vault.deposit(amount, alice);
// 没有收益时,赎回应该得到原始 amount(可能有 1 wei 舍入误差)
uint256 redeemed = vault.redeem(shares, alice, alice);
vm.stopPrank();
assertApproxEqAbs(redeemed, amount, 1, "Redeem should return deposited amount");
}
}
关键要点总结
ERC4626 核心设计思想
| 设计要点 | 说明 |
|---|---|
| 标准化接口 | 所有 Vault 遵循同一 API,降低集成成本 |
| 四种操作对称 | deposit/mint(存入侧)、withdraw/redeem(取出侧)各两种 |
| 舍入保护 Vault | 防止用户通过舍入套利,保护现有存款人 |
| Preview 函数 | 允许用户和前端预估操作结果 |
| Max 函数 | 告诉用户每种操作的上限 |
Share 价格变动公式
sharePrice = totalAssets / totalShares
当 Vault 产生收益时:
totalAssets ↑ → sharePrice ↑ → 每份 share 更值钱
当 Vault 产生亏损时:
totalAssets ↓ → sharePrice ↓ → 每份 share 贬值
ERC4626 在 DeFi 中的应用
Yield Aggregator (Yearn V3)
└── ERC4626 Vault
├── Aave Strategy (ERC4626)
├── Compound Strategy (ERC4626)
└── Curve Strategy (ERC4626)
所有策略接口统一 → 可以无缝切换/组合
常见误区
-
误区:deposit 和 mint 是一回事
- 事实:deposit 指定想存多少 assets,mint 指定想获得多少 shares。在 share 价格不是 1:1 时差别很大。
-
误区:忽略 rounding 方向
- 事实:错误的 rounding 会导致用户可以通过反复存取套利 Vault 中的资金。
- 关键原则:总是保护 Vault(有利于 Vault 的方向舍入)。
-
误区:首次存入不需要特殊处理
- 事实:首次存入(totalSupply == 0)时需要决定初始 exchange rate。通常 1:1,但有的协议会选择铸造一些"死份额"防止通胀攻击。
-
误区:totalAssets() 只是 balance
- 事实:在复杂 Vault 中,totalAssets 可能包括部署在其他协议中的资金,不只是 Vault 合约的余额。
-
误区:ERC4626 Vault 没有安全风险
- Inflation Attack(通胀攻击):攻击者在第一笔存入之前向 Vault 捐赠大量 assets,使后续存入者获得 0 shares。OpenZeppelin 通过 virtual shares/assets 机制缓解。
面试关联
Q1: 什么是 ERC4626?为什么它对 DeFi 重要?
简短回答:ERC4626 是一个标准化的带收益 Vault 接口。它让所有 Vault 遵循统一的 API,极大降低了 DeFi 协议之间的集成成本,提升了可组合性。
详细回答:
- 之前的问题:每个协议(Yearn、Compound、Aave)的 Vault 接口都不同,集成需要为每个写适配器
- ERC4626 的解决:定义了 deposit/mint/withdraw/redeem 四种操作 + preview/max 辅助函数
- 实际影响:Yearn V3、Morpho、EigenLayer 等都采用了 ERC4626,收益聚合器可以用一套代码对接所有策略
- 核心创新:shares 和 assets 的双向转换机制 + 明确的舍入规则
Q2: ERC4626 的 Inflation Attack 是什么?如何防御?
攻击流程:
- 攻击者是第一个存入者,存入 1 wei 获得 1 share
- 攻击者直接向 Vault 转入大量 assets(如 1M USDC)
- 此时 totalAssets = 1M + 1, totalShares = 1
- 受害者存入 999,999 USDC → shares = 999999 * 1 / 1000001 = 0
- 攻击者 redeem 1 share 获得所有资金
防御方法:
- OpenZeppelin 的 virtual shares/assets:初始时设置一个虚拟的偏移量
- 协议层面:要求最小首次存入量
- 铸造"死份额"给零地址
Q3: deposit vs mint 在什么场景下用?
- deposit:用户知道自己有多少 assets 想存入(最常见场景)
- mint:用户想获得精确数量的 shares(如想获得某个投票权阈值)
参考资源
- EIP-4626: Tokenized Vault Standard — 官方 EIP 文档
- OpenZeppelin ERC4626 实现 — 参考实现
- ERC4626 Alliance — 社区资源
- Yearn V3 架构 — ERC4626 实际应用
- Inflation Attack 详解 — OpenZeppelin 防御方案
- Solmate ERC4626 — 高效实现参考