ERC4626 详解

  • 0xE
  • 更新于 2024-10-11 17:10
  • 阅读 273

对 ERC4626 代币化保险库标准的详解。

原文链接:https://www.rareskills.io/post/erc4626

ERC4626 是一个代币化保险库标准,它使用 ERC20 代币来表示某种其他资产的股份。

它的工作原理是,你将一种 ERC20 代币(代币A)存入 ERC4626 合约,然后获得另一种 ERC20 代币,称为代币 S。

在这个例子中,代币 S 代表你在合约中所拥有的所有代币 A 的股份(而不是代币 A 的总供应量,仅限于 ERC4626 合约中的代币 A 余额)。

在稍后的时间里,你可以将代币 S 放回保险库合约,获得代币 A 的返还。

如果保险库中代币 A 的余额增长速度快于代币 S 的发行速度,你将按比例提取比你存入的代币 A 更多的代币 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 扩展了 ERC20 合约,并在构造阶段接受作为参数的其他 ERC20 代币,用户将把其存入该合约。

因此,ERC4626 支持你期望从 ERC20 中获得的所有功能和事件:

  • balanceOf
  • transfer
  • transferFrom
  • approve
  • allowance

等等。

这个代币被称为 ERC4626 中的股份。它就是 ERC4626 合约本身。

你拥有的股份越多,你对存入的基础资产(其他 ERC20 代币)就拥有越多的权利。

每个 ERC4626 合约只支持一种资产。你不能将多种 ERC20 代币存入合约并获得股份。

ERC4626 动机

让我们用一个真实的例子来解释这个设计。

假设我们都拥有一个公司或一个流动性池,定期赚取稳定币 DAI。在这种情况下,稳定币 DAI 是资产。

一种低效的方式是按比例将 DAI 分发给每个公司持有者。但从 gas 费的角度来看,这将非常昂贵。

同样,如果我们要在智能合约内更新每个人的余额,这也将很昂贵。

相反,使用 ERC4626,工作流程将是这样的。

假设你和九个朋友聚在一起,每个人各存入 10 DAI 到 ERC4626 保险库(总共 100 DAI)。你将获得一个股份。

到目前为止一切都很好。现在你们的公司赚取了 10 个 DAI,因此保险库中的总 DAI 现在为 110 DAI。

当你将你的股份兑换回你的 DAI 时,你不会得到 10 DAI,而是 11 DAI。

现在保险库中有 99 DAI,但有 9 个人需要分享。如果他们每个人都提取,他们将各得到 11 DAI。

请注意这有多高效。当有人进行交易时,合约中只需更改股份的总供应量和资产的数量,而不是逐个更新每个人的股份。

ERC4626 不必以这种方式使用。你可以使用任意数学公式来确定股份与资产之间的关系。例如,你可以规定每次有人提取资产时,他们还必须支付某种依赖于区块时间戳的税费或类似的费用。

ERC4626 标准提供了一种节省 gas 费的方式,用于执行非常常见的 DeFi 实践。

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,你可以对 depositmint 函数发出 staticcall,以预测会发生什么。然而,如果你在链上进行此操作,你可以使用以下两个函数:

previewDepositpreviewMint

与它们的状态变更对应函数类似,previewDeposit 以资产作为参数,而 previewMint 以股份作为参数。

预测在理想条件下你将获得多少份额

有趣的是,还有一个名为 convertToShares 的 view 函数,它以资产作为参数,并在理想条件下返回你将获得的份额数量(没有滑点或费用)。

为什么你会关心这些不反映实际交易的理想信息?

理想结果与实际结果之间的差异可以告诉你交易对市场的影响有多大,以及费用如何随着交易规模而变化。智能合约可以通过二分法查找 convertToSharespreviewMint 之间的差异,以找到最佳的交易规模来执行。

归还股份,取回资产

withdrawredeem 分别是 depositmint 的逆操作。

  • 使用 deposit,你指定要交易的资产数量,合约计算你将获得多少股份。
  • 使用 mint,你指定想要的股份数量,合约计算要从你那里取走多少资产。

同样,使用 withdraw,你可以指定想从合约中提取多少资产,合约将计算你需要销毁多少股份。

使用 redeem,你指定想要销毁多少股份,合约将计算要返还给你的资产数量。

预测你将销毁多少份额以取回资产

用于 withdrawredeem 的 view 方法分别是 previewRedeempreviewWithdraw

这些函数的理想化版本是 convertToAssets,它以份额作为参数,返回你将取回的资产数量,不包括费用和滑点。

函数总结

函数名称 状态改变还是只读的 输入的参数 返回的参数 真实的还是理想化的
deposit 状态改变 资产 股份 真实的
previewDeposit 只读 资产 股份 真实的
withdraw 状态改变 资产 股份 真实的
previewWithdraw 只读 资产 股份 真实的
convertToShares 只读 资产 股份 理想化的
mint 状态改变 股份 资产 真实的
previewMint 只读 股份 资产 真实的
redeem 状态改变 股份 资产 真实的
previewRedeem 只读 股份 资产 真实的
convertToAssets 只读 股份 资产 理想化的

地址参数

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

mintdepositredeemwithdraw 函数有第二个参数 “receiver”,用于在接收 ERC4626 合约中份额或资产的账户不是 msg.sender 的情况下。这意味着我可以将资产存入合约并指定 ERC4626 合约将份额给你。

redeemwithdraw 具有第三个参数 “owner”,允许 msg.sender 销毁 “owner” 的份额,同时将资产发送给 “receiver”(第二个参数),前提是他们有足够的授权。

maxDeposit、maxMint、maxWithdraw、maxRedeem

这些函数的参数与其对应的状态变更函数相同,并返回它们可以执行的最大交易量。此数值可因地址而异(与刚刚讲的地址参数可以对应上)。

事件

ERC4626 除了继承的 ERC20 事件之外,只有两个额外的事件:DepositWithdraw。这些事件在调用 mintredeem 时也会触发,因为本质上发生了相同的事情:代币被交换了。

滑点问题

任何代币交换协议都存在这样的问题:用户可能无法拿回他们预期数量的代币。

例如,在自动化做市商(AMM)中,一笔大额交易可能会耗尽流动性,导致价格大幅波动。

另一个问题是交易可能会被抢跑或遭遇夹击攻击。在上面的例子中,我们假设 ERC4626 合约始终保持资产和份额之间的 1:1 关系,而不考虑供给,但 ERC4626 标准并未规定定价算法应如何工作。

例如,假设我们将发行份额的数量设为所存资产的平方根函数。在这种情况下,先存入的人将获得更多的份额。这可能会鼓励投机交易者抢跑存款订单,并迫使下一个购买者为相同数量的份额支付更多资产。

对此的防御方法很简单:与 ERC4626 交互的合约应该在存款时测量接收到的份额数量(以及在提取时的资产数量),如果收到的数量未达到预期范围内的滑点容差,则 revert 交易。

这是处理滑点问题的标准设计模式。这种方式也可以防御下面描述的问题。

ERC4626 通胀攻击

虽然 ERC4626 对于将价格转换为份额的算法保持中立,但大多数实现使用的是线性关系。如果有 10,000 个资产和 100 个份额,那么 100 个资产应该对应 1 个份额。

但如果有人发送了 99 个资产会发生什么呢?它会向下取整为零,并且他们将获得零份额。

当然,没有人会故意浪费他们的资金。然而,攻击者可以通过向金库捐赠资产来抢跑交易。

如果攻击者向金库捐赠资金,那么每一份份额的价值将突然高于最初的价值。如果金库中有 10,000 个资产对应 100 个份额,而攻击者捐赠了 20,000 个资产,那么每一份份额突然从最初的 100 资产上升到 300 资产。当受害者的交易将资产换回份额时,他们突然获得的份额少了很多——可能是零。

有三种防御方法:

  1. 如果接收到的数量不在滑点容差范围内则回退交易(前面已描述)。
  2. 部署者应该向池中存入足够的资产,这样进行这种通胀攻击的成本将非常高。
  3. 向金库添加“虚拟流动性”,使定价行为表现得像池中已部署了足够的资产。

这是 OpenZeppelin 实现虚拟流动性的方法:

/**
 * @dev Internal conversion function (from assets to shares) with support for rounding direction.
 */
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) {
    return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
}

在计算存款人收到的股份数量时,总供应量被人为膨胀(程序员在_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,000 \times 1,000 \div 1,000,000$,或者正好是1。

这显然非常脆弱。如果攻击者抢先存入 1000 股份并存入资产,那么受害者将得到零返回,因为在整数除法中,一百万除以大于一百万的数字等于零。

虚拟流动性如何解决这个问题?使用上面的代码,我们会将 _decimalOffset() 设置为 3,这样 totalSupply() 就会增加 1000。

实际上,我们使分子变大了 1000 倍。这迫使攻击者进行 1000 倍大小的捐赠,这使得他们不愿意进行攻击。

资产/股份记账的真实生活例子

Compound 的早期版本为提供流动性的用户铸造了他们称为 c-tokens 的代币。例如,如果你存入 USDC,你会得到一种单独的 cUSDC(Compound USDC)作为回报。当你决定停止借贷时,你会将你的 cUSDC 送回 Compound(在那里它会被销毁),然后获得 USDC 借贷池中你应得的份额。

Uniswap 使用 LP 代币作为"股份"来代表某人向资金池中投入了多少流动性(以及当他们赎回 LP 代币换取底层资产时可以按比例提取多少)。

进一步资源

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
0xE
0xE
0x59f6...a17e
17年进入币圈,做过FHE,联盟链,现在是智能合约开发者。 刨根问底探链上真相,品味坎坷悟Web3人生。