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 数量 | 铸造的 shares | shares 向下取整 |
mint(uint256 shares, address receiver) | 想要获得的 shares | 需要支付的 assets | assets 向上取整 |
withdraw(uint256 assets, address receiver, address owner) | 想取出的 asset 数量 | 需要销毁的 shares | shares 向上取整 |
redeem(uint256 shares, address receiver, address owner) | 想赎回的 shares | 返还的 assets | assets 向下取整 |
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 损失。
攻击手法:
- 攻击者大量赎回 roETH 抵押品金库中的 shares,使金库几乎被清空
- 通过直接转账向金库注入大量 wstETH(不铸造 shares)
- 金库的每份 share 价值从 ~1.23 暴涨至 ~33.75
- 利用被操纵的高估值抵押品,从依赖该金库价格的借贷市场中借出资金
- 借贷市场的预言机未能及时检测到价格操纵
教训: 不仅金库本身有风险,依赖金库份额价格作为预言机的下游协议同样危险。
解决方案 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 的 deposit 和 withdraw 函数都需要与外部 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 可见),攻击者可以:
- 监控到即将发生的收益入账交易
- 在收益入账前大额存入(front-run)
- 在收益入账后立即赎回(back-run)
- 窃取本应属于长期持有者的收益
详细数值案例
初始状态:
• 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()准确反映全部可赎回资产 - 正确触发
Deposit和Withdraw事件 - 处理 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 文章整理