ERC4626 是一种代币化金库(tokenized vault)标准,它使用 ERC20 代币来代表某种其他资产的份额(shares)。
它的工作原理是:你将一种 ERC20 代币(代币 A)存入 ERC4626 合约,并获得另一种 ERC20 代币作为回报,我们称之为代币 S。
在这个例子中,代币 S 代表了你在该合约拥有的所有代币 A 中所占的份额(不是 A 的总供应量,而仅仅是 ERC4626 合约中 A 的余额)。
在未来的某个时间,你可以将代币 S 放回金库合约,并取回属于你的代币 A。
如果金库中代币 A 余额的增长速度快于代币 S 的产出速度,你提取出的代币 A 的数量将按比例大于你存入时的数量。
ERC4626 合约也是一个 ERC20 代币
当 ERC4626 合约因你的初始存款而发给你一个 ERC20 代币时,它给你的是代币 S(一个兼容 ERC20 的代币)。这个 ERC20 代币并不是一个单独的合约。它是在 ERC4626 合约内部实现的。事实上,你可以看到 OpenZeppelin 在 Solidity 中是如何定义这个合约的:
abstract contract ERC4626 is ERC20, IERC4626 {
using Math for uint256;
IERC20 private immutable _asset;
uint8 private immutable _underlyingDecimals;
/**
* @dev Set the underlying asset contract. This must be an ERC20-compatible contract (ERC20 or ERC777).
*/
constructor(IERC20 asset_) {
(bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_);
_underlyingDecimals = success ? assetDecimals : 18;
_asset = asset_;
}
ERC4626 Solidity 声明
ERC4626 继承了 ERC20 合约,在构造阶段,它将用户将要存入的另一种 ERC20 代币作为参数。
因此,ERC4626 支持你所期望的 ERC20 的所有函数和事件:
balanceOftransfertransferFromapproveallowance
诸如此类。
这个代币在 ERC4626 中被称为份额(shares)。它就是 ERC4626 合约本身。
你拥有的份额越多,你对存入其中的标的资产(asset,即另一种 ERC20 代币)拥有的权益就越多。
每个 ERC4626 合约仅支持一种资产。你不能将多种 ERC20 代币存入该合约并获得份额回报。
ERC4626 动机
让我们用一个真实的例子来说明这种设计的动机。
假设我们共同拥有一家公司,或者一个流动性池,它定期赚取稳定币 DAI。在这种情况下,稳定币 DAI 就是资产。
一种低效的收益分配方式是,将 DAI 按比例主动分发(push)给公司的每一位持有者。但在 gas 费用方面,这将会极其昂贵。
同样,如果我们要在智能合约内部更新每个人的余额,那也会非常昂贵。
相反,使用 ERC4626 的工作流程会是这样的:
假设你和九个朋友聚在一起,每个人向 ERC4626 金库中存入 10 DAI(总计 100 DAI)。你获得了 1 个份额。
到目前为止一切顺利。现在你的公司又赚了 10 DAI,所以金库内的 DAI 总额现在是 110 DAI。
当你用你的份额换回属于你的那部分 DAI 时,你拿回的不再是 10 DAI,而是 11 DAI。
现在金库里剩下 99 DAI,由剩下的 9 个人分享。如果他们各自提取,每人也会得到 11 DAI。
注意这种方式有多么高效。当有人进行交易时,不需要逐一更新每个人的份额,发生变化的只有份额的总供应量以及合约中资产的数量。
ERC4626 并非必须以这种方式使用。你可以使用任意数学公式来决定份额与资产之间的关系。例如,你可以设定每次有人提取资产时,他们还必须支付某种取决于区块时间戳或类似因素的税费。
ERC-4626 标准为执行非常常见的 DeFi 记账操作提供了一种 gas 高效的方式。
ERC4626 份额
很自然地,用户想要知道 ERC4626 使用的是哪种资产,以及合约拥有多少该资产,因此 ERC4626 规范中包含两个 solidity 函数来实现这一点。
function asset() returns (address)
asset 函数返回金库所使用的标的代币的地址。如果标的资产是 DAI,那么该函数将返回 DAI 的 ERC20 合约地址 0x6b175474e89094c44da98b954eedeac495271d0f。
function totalAssets() returns (uint256)
调用 totalAssets 函数将返回金库“管理”(拥有)的资产总额,即 ERC4626 合约拥有的 ERC20 代币数量。它在 OpenZeppelin 中的实现非常简单:
/** @dev See {IERC4626-totalAssets}. */
function totalAssets() public view virtual override returns (uint256) {
return _asset.balanceOf(address(this));
}
当然,这里没有获取份额地址的函数,因为那仅仅是 ERC4626 合约本身的地址。
存入资产,获得份额:deposit() 和 mint()
让我们直接从 EIP 复制粘贴执行此交易的两个规范。
// EIP: Mints a calculated number of vault shares to receiver by depositing an exact number of underlying asset tokens, specified by user.
function deposit(uint256 assets, address receiver) public virtual override returns (uint256)
// EIP: Mints exact number of vault shares to receiver, as specified by user, by calculating number of required shares of underlying asset.
function mint(uint256 shares, address receiver) public virtual override returns (uint256)
根据 EIP,用户存入资产并获得份额作为回报,那么这两个函数之间有什么区别呢?
- 使用 deposit(),你指定想要存入多少资产,函数将计算发送给你多少份额。
- 使用 mint(),你指定想要多少份额,函数将计算需要从你那里转移多少 ERC20 资产。
当然,如果你没有足够的资产转移到合约中,交易将会被回滚(revert)。
返回给你的 uint256 就是你收到的份额数量。
以下不变量应该始终为真:
// remember, erc4626 is also an erc20 token
uint256 sharesBalanceBefore = erc4626.balanceOf(address(this));
uint256 sharesReceived = erc4626.deposit(numAssets, address(this));
// strict equality checks in accounting are a big no no!
assert(erc4626.balanceOf(address(this)) >= sharesBalanceBefore + sharesReceived);
预估你将获得多少份额
如果你在使用 web3.js,你可以发起一次对 deposit 或 mint 函数的 staticcall 来预测将会发生什么。然而,如果你是在链上执行此操作,你可以使用以下两个函数:
previewDepositpreviewMint
就像它们对应的改变状态的函数一样,previewDeposit 将资产作为参数,而 previewMint 将份额作为参数。
预估在理想条件下你将获得多少份额
令人困惑的是,这里还有一个名为 convertToShares 的 view 函数,它将资产作为参数,并返回你在理想条件下(没有滑点或费用)将获得的份额数量。
既然它不能反映你将要执行的实际交易,你为什么还要关心这个理想状态下的信息呢?
理想结果与实际结果之间的差值,能告诉你你的交易对市场造成了多大影响,以及费用是如何受交易规模影响的。智能合约可以在 convertToShares 和 previewMint 的差值上进行二分查找,以找到最佳的交易执行规模。
退还份额,取回资产
deposit 和 mint 的逆操作分别是 withdraw 和 redeem。
使用 deposit 时,你指定想要交易的资产,合约会计算你获得多少份额。
使用 mint 时,你指定想要多少份额,合约会计算需要从你那里拿走多少资产。
同样地,withdraw 让你指定想从合约中取走多少资产,然后合约会计算需要销毁(burn)你多少份额。
使用 redeem 时,你指定想要销毁多少份额,然后合约会计算需要退还给你的资产数量。
预估你要销毁多少份额才能取回资产
withdraw 和 redeem 对应的 view 方法分别是 previewRedeem 和 previewWithdraw。
与这些函数对应的理想化类似物是 convertToAssets,它将份额作为参数,并告诉你将取回多少资产(不包含费用和滑点)。
迄今为止的函数汇总
| Function | 状态改变或 View | 参数 | 返回 | 理想或实际 |
|---|---|---|---|---|
| deposit | 状态改变 | assets | shares | 实际 |
| previewDeposit | view | assets | shares | 实际 |
| withdraw | 状态改变 | assets | shares | 实际 |
| previewWithdraw | view | assets | shares | 实际 |
| convertToShares | view | assets | shares | 理想 |
| mint | 状态改变 | shares | assets | 实际 |
| previewMint | view | shares | assets | 实际 |
| redeem | 状态改变 | shares | assets | 实际 |
| previewRedeem | view | shares | assets | 实际 |
| convertToAssets | view | shares | assets | 理想 |
address 参数是做什么用的?
function mint(uint256 shares, address receiver) external returns (uint256 assets);
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
mint、deposit、redeem 和 withdraw 函数都有第二个参数 “receiver”(接收者),这是为了处理从 ERC4626 接收份额或资产的账户不是 msg.sender 的情况。这意味着我可以将资产存入账户,并指定 ERC4626 合约将份额发给你。
redeem 和 withdraw 有第三个参数 “owner”(所有者),如果 msg.sender 拥有足够的授权额度(allowance),它允许 msg.sender 销毁 “owner” 的份额,同时将资产发送给 “receiver”(第二个参数)。
maxDeposit, maxMint, maxWithdraw, maxRedeem
这些函数接受与其对应的改变状态函数相同的参数,并返回它们所能执行的最大交易量。这可能会因地址而异(请记住,我们刚刚讨论过这些函数会将地址作为参数)。
事件
除了继承自 ERC20 的事件外,ERC4626 只有两个事件:Deposit 和 Withdraw。如果在调用 mint 和 redeem 时,也会触发这些事件,因为在功能上发生的是同一件事:代币被交换了。
滑点问题
任何代币交换协议都存在一个问题,即用户收回的代币数量可能与他们预期的不符。
例如,在自动做市商中,一笔大额交易可能会耗尽流动性并导致价格发生大幅波动。
另一个问题是交易被抢先交易(frontrun)或遭遇三明治攻击。在上述例子中,我们假设了无论供应量如何,ERC4626 合约都维持着资产和份额之间一对一的关系,但 ERC4626 标准并没有硬性规定定价算法应该如何运作。
例如,假设我们使发行的份额数量成为存入资产的平方根的函数。在那种情况下,谁先存入谁就会获得更多数量的份额。这可能会鼓励投机交易者抢先处理存款订单,并迫使下一个买家为同样数量的份额支付更多数量的资产。
对此的防御机制很简单:与 ERC4626 交互的合约应该测量其在存款期间收到的份额数量(以及取款期间的资产数量),如果它没有收到在一定滑点容忍度预期内的数量,则应该回滚。
这是处理滑点问题的标准设计模式。它还能防御下文将要描述的问题。
ERC4626 通胀攻击
尽管 ERC4626 并不限定将价格转换为份额的具体算法,但大多数实现都使用了线性关系。如果有 10,000 个资产和 100 个份额,那么 100 个资产应该产生 1 个份额。
但是如果有人发送 99 个资产会发生什么?它会向下取整为零,然后他们得到零个份额。
当然,没有人会故意这样白白浪费自己的钱。然而,攻击者可以通过向金库捐赠资产来对一笔交易进行抢先交易。
如果攻击者向金库捐款,单个份额的价值会突然比最初更高。如果金库中有 10,000 个资产对应 100 个份额,而攻击者捐赠了 20,000 个资产,那么 1 个份额会突然价值 300 个资产,而不是之前的 100 个。当受害者的交易用资产来换取份额时,他们突然会获得少得多的份额——甚至可能为零。
这里有三种防御措施:
- 如果收到的数量不在滑点容忍范围内,则回滚交易(如前文所述)
- 部署者应该向池中存入足够多的资产,使得执行这种通胀攻击变得过于昂贵
- 向金库中添加“虚拟流动性”(virtual liquidity),使得定价行为表现得就好像池子在部署时已经拥有了足够的资产一样。
这是 OpenZeppelin 对虚拟流动性的实现:

在计算存款人收到的份额数量时,总供应量会被人为抬高(按照程序员在 _decimalsOffset() 中指定的速率)。
让我们通过一个例子来演示。作为提醒,以上变量的含义如下:
totalSupply()= 已发行的总份额数量totalAssets()= ERC4626 持有的资产余额- assets = 用户正在存入的资产数量
公式为
shares_received = assets_deposited * totalSupply() / totalAssets();
这其中有一些实现细节:为了让结果有利于池子进行取整,并对 totalAssets() 加 1 以确保在池子为空时我们不会除以零。
假设我们有以下数字:
assets_deposited = 1,000
totalSupply() = 1,000
totalAssets() = 999,999(公式中加了 1,所以我们这样设定以便让数字更凑整)
在这种情况下,用户将获得的份额为 ,恰好等于 1。
这显然非常脆弱。如果攻击者抢在这笔 1,000 份额的存款前存入资产,那么受害者将获得零个回报,因为在整数除法中,100 万除以一个大于 100 万的数字等于零。
虚拟流动性是如何解决这个问题的呢?使用上面截图中的代码,我们可以将 _decimalOffset() 设置为 3,这样一来,totalSupply() 就会增加 1,000。
实际上,我们将分子放大了 1,000 倍。这迫使攻击者也必须进行 1,000 倍的捐赠,从而打消了他们实施攻击的念头。
份额 / 资产记账的现实案例
早期版本的 Compound 会向提供流动性的用户铸造所谓的 c-token。例如,如果你存入了 USDC,你将获得一个单独的 cUSDC(Compound USDC)作为回报。当你决定停止借贷时,你可以将你的 cUSDC 退还给 Compound(在那里它将被销毁),然后按比例获得 USDC 借贷池中的份额。
Uniswap 使用 LP 代币作为“份额”,以表示当某人用 LP 代币赎回标的资产时,他们向池子中投入了多少流动性(以及他们可以按比例提取多少资产)。
了解更多
在我们的 blockchain bootcamp 中学习更进阶的主题。
更多资源
原 EIP 作者的 Youtube 视频
Openzeppelin 的 实现
Solmate 的 实现
原载于 2023 年 2 月 17 日