ERC-4626规范总结

从程序员视角深入解析 ERC-4626 代币化金库标准,涵盖核心接口、取整方向规则、首位存款人攻击等安全问题及防御方案。

· ☕ 25 分钟阅读
ERC-4626规范总结

ERC-4626 代币化金库标准 —— 程序员视角详细总结

一、概述

ERC-4626 是以太坊上的代币化金库(Tokenized Vault)标准,为收益型金库提供统一接口。它的地位等同于 ERC-20 之于代币——ERC-4626 是金库的通用标准。

核心思想:

  • 用户存入一种 ERC-20 代币(称为 asset,如 USDC、DAI)
  • 金库铸造 shares(份额代币,本身也是 ERC-20)代表用户对底层资产的比例权益
  • 随着金库产生收益,每份 share 对应的 asset 数量增加

二、核心接口

2.1 状态变量与基础函数

函数说明
asset()返回底层 ERC-20 资产的地址
totalAssets()返回金库管理的资产总量(含收益、扣除损失/费用)
decimals()通常与底层资产一致或 asset.decimals() + offset

2.2 用户操作函数(4 个核心入口)

函数用户指定计算变量取整方向
deposit(uint256 assets, address receiver)存入的 asset 数量铸造的 sharesshares 向下取整
mint(uint256 shares, address receiver)想要获得的 shares需要支付的 assetsassets 向上取整
withdraw(uint256 assets, address receiver, address owner)想取出的 asset 数量需要销毁的 sharesshares 向上取整
redeem(uint256 shares, address receiver, address owner)想赎回的 shares返还的 assetsassets 向下取整

2.3 预览与转换函数(View)

// 转换函数 - 不包含手续费,纯数学转换
function convertToShares(uint256 assets) external view returns (uint256 shares);
function convertToAssets(uint256 shares) external view returns (uint256 assets);

// 预览函数 - 模拟实际操作结果,包含手续费等
function previewDeposit(uint256 assets) external view returns (uint256 shares);
function previewMint(uint256 shares) external view returns (uint256 assets);
function previewWithdraw(uint256 assets) external view returns (uint256 shares);
function previewRedeem(uint256 shares) external view returns (uint256 assets);

2.4 限额函数

function maxDeposit(address receiver) external view returns (uint256 maxAssets);
function maxMint(address receiver) external view returns (uint256 maxShares);
function maxWithdraw(address owner) external view returns (uint256 maxAssets);
function maxRedeem(address owner) external view returns (uint256 maxShares);

2.5 事件

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);

三、取整方向规则(核心安全约束)

原则:取整方向必须始终有利于金库(不利于用户),防止套利。

场景规则原因
用户存款获得 shares向下取整少给份额,保护金库
用户 mint 需要支付 assets向上取整多收资产,保护金库
用户取款需要销毁 shares向上取整多扣份额,保护金库
用户赎回获得 assets向下取整少给资产,保护金库

代码示例:

// 向下取整(用于 deposit / redeem)
function _convertToShares(uint256 assets) internal view returns (uint256) {
    uint256 supply = totalSupply();
    return supply == 0 ? assets : assets * supply / totalAssets();
    // 自然截断即为向下取整
}

// 向上取整(用于 withdraw / mint)
function _convertToSharesRoundUp(uint256 assets) internal view returns (uint256) {
    uint256 supply = totalSupply();
    return supply == 0 ? assets : (assets * supply + totalAssets() - 1) / totalAssets();
    // 使用 Math.ceilDiv 模式
}

四、已知安全问题与漏洞


4.1 首位存款人攻击(Inflation Attack / First Depositor Vulnerability)

问题根因详解

ERC-4626 的份额计算公式为:

shares = depositAmount × totalSupply / totalAssets

当金库刚创建时 totalSupply = 0,大多数实现对首次存款采用 1:1 比率。问题的核心在于: 攻击者可以在首次存款后,通过直接转账(不经过 deposit 函数)向金库注入大量资产,人为推高每份 share 的价值,使后续存款者因整除截断获得 0 shares。

这不是简单的”首次存入少量”的问题,而是利用了 Solidity 整数除法向下截断金库接受直接转账两个特性的组合攻击。

完整攻击案例(逐步拆解)

场景设置:

  • 金库接受 USDC(6 位小数),初始为空
  • 攻击者 Alice 持有 10,001 USDC
  • 受害者 Bob 准备存入 5,000 USDC

攻击步骤:

┌─────────────────────────────────────────────────────────────────────┐
│ 步骤 1:Alice 调用 deposit(1)                                       │
│                                                                     │
│ • totalSupply = 0,触发 1:1 逻辑                                    │
│ • Alice 获得 1 share                                                │
│ • 金库状态:totalAssets = 1, totalSupply = 1                        │
├─────────────────────────────────────────────────────────────────────┤
│ 步骤 2:Alice 直接调用 USDC.transfer(vault, 10_000e6)               │
│         注意:不经过 deposit(),不铸造任何 share!                    │
│                                                                     │
│ • 金库状态:totalAssets = 10_000_000_001, totalSupply = 1           │
│ • 每份 share 价值 = 10,000.000001 USDC                             │
├─────────────────────────────────────────────────────────────────────┤
│ 步骤 3:Bob 调用 deposit(5_000e6)                                   │
│                                                                     │
│ • shares = 5_000_000_000 × 1 / 10_000_000_001 = 0                 │
│   (整数除法截断!5000 < 10000,商为 0)                             │
│ • Bob 获得 0 shares,但 5000 USDC 已转入金库                        │
├─────────────────────────────────────────────────────────────────────┤
│ 步骤 4:Alice 调用 redeem(1)                                        │
│                                                                     │
│ • Alice 持有全部 1 share,可赎回全部金库资产                          │
│ • Alice 获得 15,000.000001 USDC                                     │
│ • 净利润:约 5,000 USDC(Bob 的全部存款)                            │
└─────────────────────────────────────────────────────────────────────┘

真实案例:Inertia Protocol 攻击事件(2026 年 5 月)

事件概要: 攻击者利用 ERC4626 金库的份额价格操控,造成约 $152,000 损失。

攻击手法:

  1. 攻击者大量赎回 roETH 抵押品金库中的 shares,使金库几乎被清空
  2. 通过直接转账向金库注入大量 wstETH(不铸造 shares)
  3. 金库的每份 share 价值从 ~1.23 暴涨至 ~33.75
  4. 利用被操纵的高估值抵押品,从依赖该金库价格的借贷市场中借出资金
  5. 借贷市场的预言机未能及时检测到价格操纵

教训: 不仅金库本身有风险,依赖金库份额价格作为预言机的下游协议同样危险。

解决方案 A:虚拟份额(Virtual Shares / Dead Shares)—— 推荐 ✅

原理: 在份额计算公式的分子分母中加入虚拟常量,使得即使金库为空,也存在一个基准比率,让攻击成本极高。

// OpenZeppelin v5 实现方式
function _decimalsOffset() internal pure virtual returns (uint8) {
    return 3; // 设置偏移量,推荐至少 3(即虚拟份额 = 1000)
}

function _convertToShares(uint256 assets, Math.Rounding rounding) internal view returns (uint256) {
    return assets.mulDiv(
        totalSupply() + 10 ** _decimalsOffset(),  // 分子加入虚拟份额
        totalAssets() + 1,                         // 分母加入虚拟资产
        rounding
    );
}

function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view returns (uint256) {
    return shares.mulDiv(
        totalAssets() + 1,                         // 分子加入虚拟资产
        totalSupply() + 10 ** _decimalsOffset(),  // 分母加入虚拟份额
        rounding
    );
}

为什么有效?_decimalsOffset() = 3 为例:

攻击者存入 1 wei 后直接转入 10,000 USDC:
• totalAssets = 10_000_000_001
• totalSupply = 1

Bob 存入 5,000 USDC:
shares = 5_000_000_000 × (1 + 1000) / (10_000_000_001 + 1)
       = 5_000_000_000 × 1001 / 10_000_000_002
       = 500  ← Bob 得到 500 shares!

攻击者只有 1 share,Bob 有 500 shares
→ 攻击者获利微乎其微,攻击不再有利可图

攻击成本分析:offset = d,攻击者需要捐赠 10^d × victim_deposit 的资产才能偷取 1 单位。当 d=3 时,要偷 1 USDC 需要先捐赠 1000 USDC,完全不划算。

解决方案 B:初始种子存款(Dead Shares Minting)

contract SecureVault is ERC4626 {
    uint256 constant SEED_DEPOSIT = 1e6; // 1 USDC (6 decimals)

    constructor(IERC20 _asset) ERC4626(_asset) ERC20("Vault", "vUSDC") {
        // 部署时存入种子资金,份额铸造给死地址
        _asset.transferFrom(msg.sender, address(this), SEED_DEPOSIT);
        _mint(address(0xdead), SEED_DEPOSIT);
        // 现在金库永远有非零的 totalSupply 和 totalAssets
    }
}

为什么有效? 死地址的份额永远不会被赎回,确保 totalSupply 永远 > 0,基准比率不可被操纵为极端值。

解决方案 C:最小存款限制 + 首存特殊逻辑

uint256 constant MIN_FIRST_DEPOSIT = 1e6;
uint256 constant DEAD_SHARES = 1e3;

function deposit(uint256 assets, address receiver) public override returns (uint256 shares) {
    if (totalSupply() == 0) {
        require(assets >= MIN_FIRST_DEPOSIT, "First deposit too small");
        // 首次存款时铸造少量 dead shares 给零地址
        _mint(address(0), DEAD_SHARES);
        shares = assets - DEAD_SHARES; // 首位存款人承担微小代价
        _mint(receiver, shares);
        SafeERC20.safeTransferFrom(asset, msg.sender, address(this), assets);
    } else {
        shares = previewDeposit(assets);
        require(shares > 0, "Zero shares");
        _mint(receiver, shares);
        SafeERC20.safeTransferFrom(asset, msg.sender, address(this), assets);
    }
    emit Deposit(msg.sender, receiver, assets, shares);
}

三种方案对比

方案优点缺点适用场景
虚拟份额 (A)无需额外交易、自动生效、零管理微量精度开销通用推荐
种子存款 (B)简单直观需部署者预存、中心化内部/DAO 管理的金库
最小限制 (C)灵活可控逻辑复杂、首存者承担代价特殊业务需求

4.2 精度损失问题(Rounding Loss Leading to Fund Loss)

问题根因详解

Solidity 没有浮点数,所有除法都是向零截断的整数除法。当 share 价值很高时(如 1 share = 1000 USDC),小额存款可能因截断获得 0 shares,资金被”吞噬”进金库但不会给任何人带来份额。

关键公式:

shares = assets × totalSupply / totalAssets

// 当 assets < totalAssets / totalSupply 时,shares = 0
// 即:存款金额 < 每份share的价值时,用户白送钱

详细案例

场景:金库运营一段时间后,产生了大量收益
• totalAssets = 1,000,000 USDC (100万)
• totalSupply = 1,000 shares
• 每份 share 价值 = 1000 USDC

用户 Charlie 存入 999 USDC:
shares = 999 × 1000 / 1,000,000 = 999,000 / 1,000,000 = 0

结果:Charlie 的 999 USDC 进入金库,但获得 0 shares
这 999 USDC 被现有份额持有者"瓜分"了

另一种精度问题 —— 赎回时的损失:

用户持有 1 share,想部分赎回 0.5 share 的价值:
assets = 1 × 1,000,000 / 1,000 = 1000 USDC  ← 全部赎回没问题

但如果用 withdraw(500):
sharesToBurn = 500 × 1000 / 1,000,000 = 0 (向上取整应为 1)
如果实现未正确向上取整,可能出错

完整解决方案

contract PrecisionSafeVault is ERC4626 {

    // 解决方案 1:检查零值
    function deposit(uint256 assets, address receiver) public override returns (uint256 shares) {
        shares = previewDeposit(assets);
        require(shares > 0, "ERC4626: zero shares minted");  // 关键检查!

        _mint(receiver, shares);
        SafeERC20.safeTransferFrom(_asset, msg.sender, address(this), assets);
        emit Deposit(msg.sender, receiver, assets, shares);
    }

    function redeem(uint256 shares, address receiver, address owner) public override returns (uint256 assets) {
        assets = previewRedeem(shares);
        require(assets > 0, "ERC4626: zero assets returned");  // 关键检查!

        _burn(owner, shares);
        SafeERC20.safeTransfer(_asset, receiver, assets);
        emit Withdraw(msg.sender, receiver, owner, assets, shares);
    }

    // 解决方案 2:提高内部精度(配合虚拟份额)
    // 设置 _decimalsOffset() 返回较大值,使 share 精度更高
    function _decimalsOffset() internal pure override returns (uint8) {
        return 6;  // share 精度 = asset精度 + 6
        // 这样 1 USDC 存入可获得 1_000_000 shares,大幅降低截断风险
    }

    // 解决方案 3:设置最小操作金额
    uint256 public constant MIN_DEPOSIT = 100; // 最小存款 100 单位

    function _checkMinimum(uint256 amount) internal pure {
        require(amount >= MIN_DEPOSIT, "Below minimum");
    }
}

为什么 _decimalsOffset 有效?

设 offset = 6,asset 为 USDC (6 decimals):
• share 精度 = 6 + 6 = 12 位
• 存入 1 USDC = 1_000_000 单位
• 获得约 1_000_000_000_000 shares (1e12)
• 只有存入 < 0.000001 USDC 时才会截断为 0

4.3 重入攻击(Reentrancy Attack)

问题根因详解

ERC-4626 的 depositwithdraw 函数都需要与外部 ERC-20 合约交互(调用 transferFrom / transfer)。如果底层 asset 是 ERC-777 代币(具有 tokensReceived 回调)或其他带 hook 的代币,攻击者可以在代币转账过程中重新进入金库函数,此时金库状态尚未完成更新。

根本原因: 违反了 Checks-Effects-Interactions (CEI) 模式——在状态更新完成前执行了外部调用。

完整攻击案例

// =========== 有漏洞的金库 ===========
contract VulnerableVault is ERC4626 {
    function deposit(uint256 assets, address receiver) public override returns (uint256 shares) {
        shares = previewDeposit(assets);

        // ❌ 危险:先执行外部调用...
        // 如果 asset 是 ERC-777,这里会触发攻击者的 tokensReceived 回调
        SafeERC20.safeTransferFrom(_asset, msg.sender, address(this), assets);

        // ...然后才更新状态
        _mint(receiver, shares);  // 此时攻击者已经重入了!
        emit Deposit(msg.sender, receiver, assets, shares);
    }
}

// =========== 攻击合约 ===========
contract VaultReentrancyAttacker is IERC777Recipient {
    VulnerableVault public vault;
    IERC777 public token;
    uint256 public attackCount;
    uint256 constant MAX_REENTRIES = 5;

    constructor(address _vault, address _token) {
        vault = VulnerableVault(_vault);
        token = IERC777(_token);
        // 注册为 ERC-777 接收者
        IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24)
            .setInterfaceImplementer(address(this), keccak256("ERC777TokensRecipient"), address(this));
    }

    function attack(uint256 amount) external {
        token.approve(address(vault), type(uint256).max);
        vault.deposit(amount, address(this));
    }

    // ERC-777 回调 —— 在 transfer 过程中被触发
    function tokensReceived(
        address, address from, address to,
        uint256 amount, bytes calldata, bytes calldata
    ) external override {
        // 此时金库的 _mint 尚未执行
        // totalSupply 还是旧值 → 我们能以过低的价格获得更多 shares!
        if (attackCount < MAX_REENTRIES) {
            attackCount++;
            vault.deposit(amount, address(this));
            // 重入!金库状态不一致,能获得额外 shares
        }
    }
}

攻击流程图:

Attacker.attack(1000)
  → Vault.deposit(1000)
    → token.transferFrom(attacker, vault, 1000)
      → [ERC-777 hook] Attacker.tokensReceived()
        → Vault.deposit(1000)  ← 重入!
          → token.transferFrom(attacker, vault, 1000)
            → [ERC-777 hook] Attacker.tokensReceived()
              → Vault.deposit(1000)  ← 再次重入!
                ...(金库状态始终未更新,每次都按旧比率铸造)
              ← _mint(attacker, shares)  ← 用过时的 totalSupply 计算
            ← _mint(attacker, shares)
          ← _mint(attacker, shares)
        ← _mint(attacker, shares)
      ← _mint(attacker, shares)

完整解决方案

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SecureVault is ERC4626, ReentrancyGuard {

    // 方案 1:ReentrancyGuard (最简单、最可靠)
    function deposit(uint256 assets, address receiver)
        public override nonReentrant returns (uint256 shares)
    {
        shares = previewDeposit(assets);
        require(shares > 0, "Zero shares");
        _mint(receiver, shares);
        SafeERC20.safeTransferFrom(_asset, msg.sender, address(this), assets);
        emit Deposit(msg.sender, receiver, assets, shares);
    }

    function withdraw(uint256 assets, address receiver, address owner)
        public override nonReentrant returns (uint256 shares)
    {
        shares = previewWithdraw(assets);
        _burn(owner, shares);
        SafeERC20.safeTransfer(_asset, receiver, assets);
        emit Withdraw(msg.sender, receiver, owner, assets, shares);
    }

    // 方案 2:严格 CEI 模式 (Checks → Effects → Interactions)
    // 注意:OpenZeppelin 的 ERC4626 在 deposit 中先 transfer 再 mint
    // 但在 withdraw 中先 burn 再 transfer —— 这是经过审计的安全顺序
    //
    // deposit: transfer-then-mint 是安全的,因为:
    //   - 如果重入 deposit:攻击者需要再次付款,不会获得额外好处
    //   - 如果重入 withdraw:攻击者还没有 shares,无法赎回
    //
    // withdraw: burn-then-transfer 是安全的,因为:
    //   - shares 已销毁,重入 withdraw 会因余额不足失败
}

OpenZeppelin 的设计哲学解释:

deposit 顺序:transferFrom → mint(先收钱后给份额)
  为什么安全?即使重入,攻击者每次都要付真金白银

withdraw 顺序:burn → transfer(先烧份额后给钱)
  为什么安全?即使重入,份额已销毁,无法再次赎回

这种设计让 ReentrancyGuard 变为可选但推荐的额外防线

4.4 三明治攻击 / 收益窃取(Sandwich Attack / Yield Sniping)

问题根因详解

ERC-4626 金库的收益通常以离散事件形式入账(如:借贷利息结算、策略收割、奖励分发)。这创造了一个时间窗口:

时间线:
─────────────────────────────────────────────────────────────
  T1: 攻击者存入     T2: 收益入账     T3: 攻击者赎回
─────────────────────────────────────────────────────────────
      │                    │                   │
      ▼                    ▼                   ▼
  获得 shares          share 增值          获得超额收益

核心问题: 区块链上的交易是公开的(mempool 可见),攻击者可以:

  1. 监控到即将发生的收益入账交易
  2. 在收益入账前大额存入(front-run)
  3. 在收益入账后立即赎回(back-run)
  4. 窃取本应属于长期持有者的收益

详细数值案例

初始状态:
• totalAssets = 100,000 USDC
• totalSupply = 100,000 shares
• 1 share = 1 USDC
• 长期持有者 Bob 持有 50,000 shares

攻击流程:
─────────────────────────────────────────────────────────
Step 1: 攻击者发现收益交易即将入账 10,000 USDC

Step 2: 攻击者在收益交易前存入 100,000 USDC
• shares_minted = 100,000 × 100,000 / 100,000 = 100,000 shares
• 金库状态:totalAssets = 200,000, totalSupply = 200,000

Step 3: 收益入账 10,000 USDC
• 金库状态:totalAssets = 210,000, totalSupply = 200,000
• 1 share = 1.05 USDC

Step 4: 攻击者立即赎回全部 100,000 shares
• assets_received = 100,000 × 210,000 / 200,000 = 105,000 USDC
• 攻击者利润 = 5,000 USDC

如果没有攻击者:
• Bob 应得收益 = 10,000 × 50,000/100,000 = 5,000 USDC
• 实际 Bob 收益 = 10,000 × 50,000/200,000 = 2,500 USDC
• Bob 损失了 2,500 USDC 的收益
─────────────────────────────────────────────────────────

完整解决方案

方案 1:存款冷却期(Cooldown Period)

contract CooldownVault is ERC4626 {
    uint256 public constant COOLDOWN_BLOCKS = 100; // 约 20 分钟
    mapping(address => uint256) public lastDepositBlock;

    function deposit(uint256 assets, address receiver) public override returns (uint256 shares) {
        lastDepositBlock[receiver] = block.number;
        shares = super.deposit(assets, receiver);
    }

    function redeem(uint256 shares, address receiver, address owner) public override returns (uint256 assets) {
        require(
            block.number >= lastDepositBlock[owner] + COOLDOWN_BLOCKS,
            "Cooldown period not elapsed"
        );
        assets = super.redeem(shares, receiver, owner);
    }

    function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256 assets_out) {
        require(
            block.number >= lastDepositBlock[owner] + COOLDOWN_BLOCKS,
            "Cooldown period not elapsed"
        );
        assets_out = super.withdraw(assets, receiver, owner);
    }

    // 注意:share 的 transfer 也需要重置冷却期,否则攻击者可以转给另一个地址绕过
    function _update(address from, address to, uint256 value) internal override {
        super._update(from, to, value);
        if (from != address(0) && to != address(0)) {
            lastDepositBlock[to] = block.number;
        }
    }
}

方案 2:收益线性释放(Yield Streaming / Drip)

contract StreamingVault is ERC4626 {
    uint256 public constant DISTRIBUTION_PERIOD = 7 days;
    uint256 public lastDistributionTimestamp;
    uint256 public pendingYield;
    uint256 public distributedYield;

    // 收益不会一次性反映在 totalAssets 中
    // 而是在 DISTRIBUTION_PERIOD 内线性释放
    function totalAssets() public view override returns (uint256) {
        uint256 baseAssets = _asset.balanceOf(address(this));
        uint256 elapsed = block.timestamp - lastDistributionTimestamp;

        if (elapsed >= DISTRIBUTION_PERIOD) {
            return baseAssets; // 已全部释放
        }

        // 未释放的收益不计入 totalAssets
        uint256 unreleased = pendingYield * (DISTRIBUTION_PERIOD - elapsed) / DISTRIBUTION_PERIOD;
        return baseAssets - unreleased;
    }

    // 当新收益到达时调用
    function notifyYield(uint256 amount) external onlyStrategy {
        // 将未释放的旧收益累加
        uint256 elapsed = block.timestamp - lastDistributionTimestamp;
        if (elapsed < DISTRIBUTION_PERIOD) {
            uint256 remaining = pendingYield * (DISTRIBUTION_PERIOD - elapsed) / DISTRIBUTION_PERIOD;
            amount += remaining;
        }

        pendingYield = amount;
        lastDistributionTimestamp = block.timestamp;
    }
}

为什么线性释放有效?

攻击者在 T1 存入,收益在 T2 入账:
• 但收益在 7 天内线性释放
• 攻击者若在 T3 (收益后 1 小时) 赎回:
  只能获得 1/168 (约 0.6%) 的新收益
• 剩余 99.4% 的收益会在接下来 7 天分给真正的长期持有者
• 攻击变得无利可图

方案 3:入场/出场费用

contract FeeVault is ERC4626 {
    uint256 public constant ENTRY_FEE_BPS = 10;  // 0.1% 入场费
    uint256 public constant EXIT_FEE_BPS = 10;   // 0.1% 出场费
    uint256 constant BPS_DENOMINATOR = 10000;
    address public feeRecipient;

    function previewDeposit(uint256 assets) public view override returns (uint256) {
        uint256 fee = assets * ENTRY_FEE_BPS / BPS_DENOMINATOR;
        return super.previewDeposit(assets - fee);
    }

    function previewRedeem(uint256 shares) public view override returns (uint256) {
        uint256 assets = super.previewRedeem(shares);
        uint256 fee = assets * EXIT_FEE_BPS / BPS_DENOMINATOR;
        return assets - fee;
    }

    // 费用使快进快出无利可图
    // 攻击者利润必须 > 入场费 + 出场费 才有动力
}

4.5 非标准 ERC-20 兼容性问题

问题根因详解

ERC-4626 规范假设底层 asset 是”标准” ERC-20,即 transfer(to, amount) 后接收方余额恰好增加 amount。但现实中存在大量”非标准”代币:

代币类型偏离标准的行为影响
Fee-on-transfer每次转账扣税 2-5%金库实际收到的比记录的少
Rebasing (正向)余额自动增长(如 stETH)totalAssets 超预期增长
Rebasing (负向)余额自动缩减(如负利率)totalAssets 意外减少
无返回值transfer 不返回 bool调用 revert(需 SafeERC20)
超额扣费approve 时扣手续费approve 金额不够

Fee-on-Transfer 代币详细案例

代币 TAX 每次转账收取 5% 手续费

场景:用户向金库存入 1000 TAX
─────────────────────────────────────────────────────────
错误实现的后果:
1. 用户调用 deposit(1000)
2. 金库调用 TAX.transferFrom(user, vault, 1000)
3. 实际到账:1000 × 95% = 950 TAX
4. 金库按 1000 计算 shares → 多发了 shares!
5. 累计下来,金库总是入不敷出
6. 最后一批赎回者无法取回资金(金库余额不足)

具体数值:
• 初始:totalAssets = 10,000, totalSupply = 10,000
• 用户存入 1000 TAX,实际到账 950
• 金库按 1000 计算:shares = 1000 × 10000 / 10000 = 1000 shares
• 但实际只有 10,950 asset 对应 11,000 shares
• 亏空 = 50 TAX,逐渐累积
─────────────────────────────────────────────────────────

完整解决方案

contract FeeOnTransferSafeVault is ERC4626 {

    // ===== 处理 Fee-on-transfer 代币 =====
    function deposit(uint256 assets, address receiver) public override returns (uint256 shares) {
        // 记录转账前余额
        uint256 balanceBefore = _asset.balanceOf(address(this));

        // 执行转账
        SafeERC20.safeTransferFrom(_asset, msg.sender, address(this), assets);

        // 计算实际到账金额(核心修复!)
        uint256 actualReceived = _asset.balanceOf(address(this)) - balanceBefore;

        // 使用实际到账金额计算 shares
        shares = _convertToShares(actualReceived, Math.Rounding.Floor);
        require(shares > 0, "Zero shares");

        _mint(receiver, shares);
        emit Deposit(msg.sender, receiver, actualReceived, shares);
    }

    function withdraw(uint256 assets, address receiver, address owner)
        public override returns (uint256 shares)
    {
        shares = previewWithdraw(assets);
        _burn(owner, shares);

        // 对于 fee-on-transfer,用户实际收到的会少于 assets
        // 需要在文档/UI 中明确告知用户
        SafeERC20.safeTransfer(_asset, receiver, assets);

        emit Withdraw(msg.sender, receiver, owner, assets, shares);
    }

    // 重写 totalAssets 使用实际余额
    function totalAssets() public view override returns (uint256) {
        return _asset.balanceOf(address(this));
    }
}

Rebasing 代币详细案例与方案

代币 stETH:每天自动 rebase +0.01%(代表质押收益)

问题场景:
─────────────────────────────────────────────────────────
Day 0: 用户存入 1000 stETH,获得 1000 shares
       totalAssets = 1000, totalSupply = 1000

Day 1: stETH rebase +0.01%
       金库余额自动变为 1000.1 stETH
       但 totalSupply 仍然是 1000 shares
       每份 share = 1.0001 stETH

问题:这个"收益"应该归谁?
• 如果金库是被动持有:收益自然归所有 share 持有者(合理)
• 如果金库有策略,totalAssets 计算可能不准确
• 负向 rebase 时:用户可能赎回比存入时更少的资产(但 share 计算可能出错)
─────────────────────────────────────────────────────────

推荐做法:使用 Wrapper 代币

// 最佳方案:不直接接受 rebasing 代币,使用 wrapped 版本
// 例如:不用 stETH,用 wstETH(fixed-balance wrapper)

contract SafeRebasingVault is ERC4626 {
    // 只接受 wstETH 而非 stETH
    constructor() ERC4626(IERC20(WSTETH_ADDRESS)) {}
    // wstETH 余额不会自动变化,收益体现在 wstETH→stETH 的汇率中
}

// 如果必须接受 rebasing 代币:
contract RebasingAwareVault is ERC4626 {
    uint256 private _lastKnownBalance;

    function totalAssets() public view override returns (uint256) {
        // 始终使用实时余额,不缓存
        return _asset.balanceOf(address(this));
    }

    // 在每次操作前同步状态
    modifier syncBalance() {
        _lastKnownBalance = _asset.balanceOf(address(this));
        _;
    }
}

4.6 totalAssets() 准确性问题

问题根因详解

totalAssets() 是 ERC-4626 的定价之锚——所有 share 的估值都依赖它。如果这个值不准确,会导致:

  • 高估:用户赎回时金库余额不足(bank run)
  • 低估:用户存款获得过多 shares(稀释现有持有者)

常见错误场景:

场景:金库将 80% 资金部署到 Aave 借贷

错误实现:
function totalAssets() public view returns (uint256) {
    return asset.balanceOf(address(this)); // ❌ 只统计了金库内的 20%!
}

正确值 = 金库余额 + Aave 存款本金 + Aave 累计利息 - 未偿付的费用

详细案例

金库管理 1,000,000 USDC:
─────────────────────────────────────────────────────────
资产分布:
• 金库合约内:200,000 USDC(用于即时赎回流动性)
• Aave v3 存款:600,000 USDC + 12,000 利息
• Compound 存款:200,000 USDC + 5,000 利息
• 管理费累计:3,000 USDC(待扣除)

正确的 totalAssets:
= 200,000 + 612,000 + 205,000 - 3,000
= 1,014,000 USDC

如果只返回 balanceOf:
= 200,000 USDC ← 严重低估!
所有人赎回只能拿到 20% 的应得资产
─────────────────────────────────────────────────────────

完整解决方案

contract MultiStrategyVault is ERC4626 {
    IPool public aavePool;
    ICToken public compoundMarket;
    uint256 public accumulatedFees;

    function totalAssets() public view override returns (uint256 total) {
        // 1. 金库内的闲置资产
        total = _asset.balanceOf(address(this));

        // 2. Aave 中的存款(含利息)
        total += aavePool.getBalance(address(this), address(_asset));

        // 3. Compound 中的存款(含利息)
        uint256 cTokenBalance = compoundMarket.balanceOf(address(this));
        uint256 exchangeRate = compoundMarket.exchangeRateStored();
        total += cTokenBalance * exchangeRate / 1e18;

        // 4. 扣除待收取的管理费
        total -= accumulatedFees;

        // 5. 扣除已知的坏账(如果有)
        total -= _knownBadDebt;
    }

    // 注意事项:
    // - exchangeRateStored() vs exchangeRateCurrent():前者不消耗 gas 但可能过时
    // - 跨协议的预言机价格延迟可能导致短暂不准确
    // - 建议定期调用 harvest() 更新精确值
}

关键设计原则:

totalAssets() 应该回答:
"如果现在所有用户立即赎回,能拿到多少?"

不应包含:
• 锁定中无法立即取出的资产(如有锁定期的质押)
• 乐观估值的未实现收益
• 还未结算的奖励代币(除非有确定性市场价格)

应包含:
• 所有可立即变现的外部头寸
• 已结算的利息和收益
• 扣除所有费用和损失

4.7 Preview 函数与实际操作不一致

问题根因详解

ERC-4626 规范严格要求:在同一区块内,previewDeposit(x) 的返回值必须等于调用 deposit(x) 实际铸造的 shares 数。这不是”建议”,是强制性约束(MUST 级别)。

违反此约束的后果:

  • 集成方(如聚合器、路由器)无法正确计算滑点
  • 用户界面显示错误的预期结果
  • 可能被利用进行套利

常见违规案例

// ═══════════════════════════════════════════════════════════════
// 案例 1:preview 未包含手续费
// ═══════════════════════════════════════════════════════════════
contract BrokenFeeVault {
    uint256 constant FEE_BPS = 100; // 1%

    // ❌ 错误:preview 忽略了手续费
    function previewDeposit(uint256 assets) public view returns (uint256) {
        return _convertToShares(assets);  // 返回 1000 shares
    }

    function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
        uint256 fee = assets * FEE_BPS / 10000;
        uint256 netAssets = assets - fee;    // 扣费后才计算
        shares = _convertToShares(netAssets); // 实际只铸造 990 shares
        // preview 说 1000,实际给 990 → 违反规范!
    }
}

// ═══════════════════════════════════════════════════════════════
// 案例 2:preview 未反映动态条件
// ═══════════════════════════════════════════════════════════════
contract BrokenDynamicVault {
    bool public paused;

    // ❌ 错误:preview 在暂停时仍返回正常值
    function previewDeposit(uint256 assets) public view returns (uint256) {
        return _convertToShares(assets);  // 返回 1000 shares
    }

    function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
        require(!paused, "Vault paused"); // 但实际会 revert!
        shares = _convertToShares(assets);
    }
    // 规范要求:暂停时 previewDeposit 应 revert 或 maxDeposit 应返回 0
}

// ═══════════════════════════════════════════════════════════════
// 案例 3:preview 与实际的取整不一致
// ═══════════════════════════════════════════════════════════════
contract BrokenRoundingVault {
    // ❌ 错误:preview 用了不同的取整方式
    function previewMint(uint256 shares) public view returns (uint256) {
        return shares * totalAssets() / totalSupply(); // 向下取整
    }

    function mint(uint256 shares, address receiver) public returns (uint256 assets) {
        assets = (shares * totalAssets() + totalSupply() - 1) / totalSupply(); // 向上取整
        // preview 返回 999,实际收取 1000 → 不一致!
    }
}

完整正确实现

contract CorrectPreviewVault is ERC4626 {
    uint256 public constant DEPOSIT_FEE_BPS = 50;  // 0.5%
    uint256 public constant WITHDRAW_FEE_BPS = 50;
    uint256 constant BPS = 10000;

    // ═══════ 抽取共享逻辑,确保 preview 和实际操作使用完全相同的代码路径 ═══════

    function _computeDepositShares(uint256 assets) internal view returns (uint256 shares, uint256 fee) {
        fee = assets * DEPOSIT_FEE_BPS / BPS;
        uint256 netAssets = assets - fee;
        shares = _convertToShares(netAssets, Math.Rounding.Floor);
    }

    function _computeWithdrawShares(uint256 assets) internal view returns (uint256 shares, uint256 fee) {
        fee = assets * WITHDRAW_FEE_BPS / BPS;
        uint256 grossAssets = assets + fee; // 用户需要额外支付费用对应的 shares
        shares = _convertToShares(grossAssets, Math.Rounding.Ceil);
    }

    // Preview 函数直接复用内部计算
    function previewDeposit(uint256 assets) public view override returns (uint256) {
        (uint256 shares, ) = _computeDepositShares(assets);
        return shares;
    }

    function previewWithdraw(uint256 assets) public view override returns (uint256) {
        (uint256 shares, ) = _computeWithdrawShares(assets);
        return shares;
    }

    // 实际操作也复用相同逻辑
    function deposit(uint256 assets, address receiver) public override returns (uint256 shares) {
        (shares, uint256 fee) = _computeDepositShares(assets);
        require(shares > 0, "Zero shares");

        SafeERC20.safeTransferFrom(_asset, msg.sender, address(this), assets);
        _mint(receiver, shares);
        _collectFee(fee);
        emit Deposit(msg.sender, receiver, assets, shares);
    }

    // 关键原则:preview 和实操必须走同一个 _compute 函数
    // 永远不要在两个地方重复计算逻辑!
}

规范合规检查清单:

对于每一对 preview/action 函数:
✅ previewDeposit(x)  == deposit(x) 铸造的 shares 数
✅ previewMint(x)     == mint(x) 消耗的 assets 数
✅ previewWithdraw(x) == withdraw(x) 销毁的 shares 数
✅ previewRedeem(x)   == redeem(x) 返还的 assets 数

附加要求:
✅ 当操作会 revert 时,preview 也应该 revert(或 max* 返回 0)
✅ preview 的取整方向与实际操作完全一致
✅ 手续费、滑点等所有因素都被 preview 计入

五、最佳实践总结

实现清单

  • 使用 OpenZeppelin 的 ERC4626 实现作为基础
  • 加入虚拟份额防止首位存款人攻击
  • 所有取整方向符合规范(有利于金库)
  • 检查 shares/assets 计算结果不为 0
  • 使用 SafeERC20 进行所有代币操作
  • 使用 nonReentrant 修饰符或严格 CEI 模式
  • Preview 函数与实际操作保持一致
  • totalAssets() 准确反映全部可赎回资产
  • 正确触发 DepositWithdraw 事件
  • 处理 max* 函数的边界(如暂停时返回 0)
  • 考虑 fee-on-transfer 代币兼容性
  • 添加存款冷却期防止三明治攻击(按需)

测试清单

  • 首次存款场景
  • 多人存款后赎回比例正确
  • 金库为空时的边界情况
  • 精度损失场景(极小额存取)
  • 收益入账后 share 价值增长
  • Preview 函数与实际操作结果一致性
  • 重入攻击模拟
  • 通缩攻击模拟(直接转账扰乱比率)
  • Fee-on-transfer 代币场景

六、OpenZeppelin 参考实现要点

// OpenZeppelin v5 ERC4626 核心结构
abstract contract ERC4626 is ERC20, IERC4626 {
    IERC20 private immutable _asset;
    uint8 private immutable _underlyingDecimals;

    // 虚拟偏移量,防通胀攻击
    function _decimalsOffset() internal pure virtual returns (uint8) {
        return 0;
    }

    function _convertToShares(uint256 assets, Math.Rounding rounding) internal view returns (uint256) {
        return assets.mulDiv(
            totalSupply() + 10 ** _decimalsOffset(),
            totalAssets() + 1,
            rounding
        );
    }

    function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view returns (uint256) {
        return shares.mulDiv(
            totalAssets() + 1,
            totalSupply() + 10 ** _decimalsOffset(),
            rounding
        );
    }
}

关键设计决策:

  • +1+10**offset 就是虚拟份额/资产的实现
  • Math.Rounding 参数化取整方向
  • mulDiv 使用全精度中间结果避免溢出

七、参考资料


文档生成时间:2026-05-28 | 基于 EIP-4626 规范与 RareSkills 文章整理

💬 评论