返回 SC 笔记
SC Day 32

Solidity - Vault 合约 (ERC4626) - deposit/withdraw/shares 计算

### 一、ERC4626 是什么?为什么需要它?

2026-05-02
第二阶段:框架实战
SolidityERC4626VaultDeFiShareCalculation

日期: 2026-05-02 方向: Solidity 阶段: 第二阶段:框架实战 标签: #Solidity #ERC4626 #Vault #DeFi #ShareCalculation


今日目标

  1. 深入理解 ERC4626 Tokenized Vault Standard 的设计动机和核心接口
  2. 掌握 shares 与 assets 之间的转换数学(deposit/withdraw/mint/redeem)
  3. 实现一个完整的 SimpleVault 合约,包含 share 计算、rounding 处理、边界情况
  4. 理解 ERC4626 为什么对 DeFi 可组合性至关重要

核心概念

一、ERC4626 是什么?为什么需要它?

问题背景

在 ERC4626 出现之前,每个 DeFi 协议实现 Vault(金库/资金池)的方式都不同:

协议Vault 实现方式
Yearn V2yToken,自定义 pricePerShare
CompoundcToken,exchangeRate 计算
AaveaToken,rebasing 模型
SushiswapxSUSHI,简单比例计算

这导致了几个严重问题:

  • 集成成本高:每次集成新协议都要写适配器
  • 安全审计难:每种实现都有独特的漏洞风险面
  • 可组合性差:无法构建通用的收益聚合器

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)

所有策略接口统一 → 可以无缝切换/组合

常见误区

  1. 误区:deposit 和 mint 是一回事

    • 事实:deposit 指定想存多少 assets,mint 指定想获得多少 shares。在 share 价格不是 1:1 时差别很大。
  2. 误区:忽略 rounding 方向

    • 事实:错误的 rounding 会导致用户可以通过反复存取套利 Vault 中的资金。
    • 关键原则:总是保护 Vault(有利于 Vault 的方向舍入)。
  3. 误区:首次存入不需要特殊处理

    • 事实:首次存入(totalSupply == 0)时需要决定初始 exchange rate。通常 1:1,但有的协议会选择铸造一些"死份额"防止通胀攻击。
  4. 误区:totalAssets() 只是 balance

    • 事实:在复杂 Vault 中,totalAssets 可能包括部署在其他协议中的资金,不只是 Vault 合约的余额。
  5. 误区: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. 攻击者是第一个存入者,存入 1 wei 获得 1 share
  2. 攻击者直接向 Vault 转入大量 assets(如 1M USDC)
  3. 此时 totalAssets = 1M + 1, totalShares = 1
  4. 受害者存入 999,999 USDC → shares = 999999 * 1 / 1000001 = 0
  5. 攻击者 redeem 1 share 获得所有资金

防御方法

  • OpenZeppelin 的 virtual shares/assets:初始时设置一个虚拟的偏移量
  • 协议层面:要求最小首次存入量
  • 铸造"死份额"给零地址

Q3: deposit vs mint 在什么场景下用?

  • deposit:用户知道自己有多少 assets 想存入(最常见场景)
  • mint:用户想获得精确数量的 shares(如想获得某个投票权阈值)

参考资源

  1. EIP-4626: Tokenized Vault Standard — 官方 EIP 文档
  2. OpenZeppelin ERC4626 实现 — 参考实现
  3. ERC4626 Alliance — 社区资源
  4. Yearn V3 架构 — ERC4626 实际应用
  5. Inflation Attack 详解 — OpenZeppelin 防御方案
  6. Solmate ERC4626 — 高效实现参考