ERC4626 代币化金库接口详解

ERC4626 是一种使用 ERC20 代币表示其他资产份额的代币化金库标准。其工作原理是你将一个 ERC20 代币(代币 A)存入 ERC4626 合约,然后得到另一个 ERC20 代币,称为代币 S。

ERC4626 代币化金库接口详解
一键发币: Aptos | X Layer | SUI | SOL | BNB | ETH | BASE | ARB | OP | Polygon | Avalanche | 用AI学区块链开发

ERC4626 是一种使用 ERC20 代币表示其他资产份额的代币化金库标准。

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

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

在以后的日期,你可以将代币 S 放回金库合约,并得到代币 A 返回给你。

如果金库中代币 A 的余额增长速度比代币 S 的产生速度快,那么你将按比例收回更多的代币 A。

1、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_;
    }

2、ERC4626 Solidity 声明

ERC4626 扩展了 ERC20 合约,在构造阶段,它将作为参数传入用户要存入的其他 ERC20 代币。

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

  • balanceOf
  • transfer
  • transferFrom
  • approve
  • allowance

等等。

这个代币在 ERC4626 中被称为 shares。这就是 ERC4626 合约本身。

你拥有的股份越多,你就越有权访问被存入其中的底层 asset(另一个 ERC20 代币)。

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

3、ERC4626 的动机

让我们用一个真实例子来说明设计的动机。

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

一种低效的分配收益的方式是按比例向公司每个持有者推送 DAI。但这在 gas 方面非常昂贵。

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

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

假设你和九个朋友一起,每人存入 10 DAI 到 ERC4626 金库(总共 100 DAI)。你会得到一个股份。

到目前为止,很好。现在你的公司赚了 10 个额外的 DAI,所以金库中的 DAI 总额现在是 110 DAI。

当你把你的股份换回你部分的 DAI 时,你不会得到 10 DAI,而是 11。

现在金库中有 99 DAI,但有 9 个人要分享。如果他们每个人都要提取,他们会得到 11 DAI 每人。

请注意这种效率。当有人进行交易时,不需要逐个更新每个人的股份,只有股份的总供应量和合同中的资产数量发生变化。

ERC4626 不一定要这样使用。你可以有一个任意的数学公式来确定股份和资产之间的关系。例如,你可以规定每次有人提取资产时,他们还必须支付某种基于区块时间戳的税。

ERC 4626 标准为执行非常常见的 DeFi 会计实践提供了 gas 效率高的方法。

4、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 合约的地址。

5、给出资产,获得股份:deposit() 和 mint()

让我们直接从 EIP 中复制并粘贴这两个规范,以进行此交易。

// EIP: 通过存入用户指定的精确数量的底层资产代币,向接收者铸造计算出的金库股份数。

function deposit(uint256 assets, address receiver) public virtual override returns (uint256)
// EIP: 通过计算所需底层资产的股份数,向接收者铸造指定数量的金库股份。

function mint(uint256 shares, address receiver) public virtual override returns (uint256)

根据 EIP,用户正在存入资产并获得股份,那么这两个函数之间有什么区别?

  • 使用 deposit(),你 指定你想存入多少资产,然后该函数会计算要发送给你的股份。
  • 使用 mint(),你 指定你想获得多少股份,然后该函数会计算你需要从你那里转移的 ERC20 资产数量。

当然,如果你没有足够的资产转移到合同中,交易将会失败。

返回给你的 uint256 是你获得的股份数量。

以下不变量应该始终为真

// 记住,erc4626 也是一个 erc20 代币
uint256 sharesBalanceBefore = erc4626.balanceOf(address(this));
uint256 sharesReceived = erc4626.deposit(numAssets, address(this));

// 在会计中严格相等检查是一个大忌!
assert(erc4626.balanceOf(address(this)) >= sharesBalanceBefore + sharesReceived);

6、预测你将获得多少股份

如果你使用 web3.js,你可以发出 staticcall 来 deposit 或 mint 函数来预测会发生什么。然而,如果你在链上这样做,你有两个可用的函数:

  • previewDeposit
  • previewMint

像它们的状态更改对应项一样,previewDeposit 以资产作为参数,previewMint 以股份作为参数。

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

令人困惑的是,还有一个名为 convertToShares 的视图函数,它以资产作为参数并返回在理想条件下(无滑点或费用)你将获得的股份数量。

为什么你会关心这个不反映你将执行的交易的理想信息?

理想结果和实际结果之间的差异告诉你你的交易对市场的影响有多大,以及费用如何依赖于交易规模。智能合约可以对 convertToSharespreviewMint 之间的差异进行二分搜索,以找到最佳交易规模来执行。

7、归还股份,取回资产

deposit 和 mint 的反义词分别是 withdraw 和 redeem。

在 deposit 中,你指定想要交易的资产,合约会计算你将获得的股份。

在 mint 中,你指定你想要多少股份,合约会计算需要从你那里取走的资产数量。

同样,withdraw 允许你指定想要从合约中取出多少 资产,合约会计算要燃烧的你的股份数量。

在 redeem 中,你指定想要 燃烧 多少股份,合约会计算要返还的资产数量。

8、预测你要燃烧多少股份以取回资产

withdraw 和 redeem 的视图方法分别是 previewRedeempreviewWithdraw

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

9、到目前为止的功能总结

功能状态更改或视图作为参数返回理想或实际
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);

mint、deposit、redeem 和 withdraw 函数有一个第二个参数 “receiver”,用于账户接收来自 ERC4626 的股份或资产不是 msg.sender 的情况。这意味着我可以将资产存入账户并指定 ERC4626 合约将股份给你。

redeem 和 withdraw 有一个第三个参数 “owner”,允许 msg.sender 燃烧 “owner”的股份,同时将资产发送给 “receiver”(第二个参数),如果他们有权限这样做的话。

maxDeposit、maxMint、maxWithdraw、maxRedeem

这些函数与它们的状态更改对应项采用相同的参数,并返回它们可以执行的最大交易。这可能因地址而异(记住,我们刚刚讨论过这些函数接受地址作为参数)。

事件

除了继承的 ERC20 事件外,ERC4626 还有两个事件:Deposit 和 Withdraw。如果调用了 mint 和 redeem,也会发出这些事件,因为功能上发生了同样的事情:代币被交换了。

10、滑点问题

任何代币交换协议都有一个问题,即用户可能无法获得他们预期的代币数量。

例如,自动做市商中,大额交易可能会耗尽流动性并导致价格大幅波动。

另一个问题是交易被抢先或遭遇夹击攻击。在上面的例子中,我们假设 ERC4626 合约无论供应量如何都保持资产和股份的一对一关系,但 ERC4626 标准并未规定定价算法应如何工作。

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

对此的防御很简单:与 ERC4626 交互的合约应在存款时测量收到的股份数量(并在取款时测量资产数量),如果未在一定滑点容忍度内收到预期数量,则回滚。

这是一种处理滑点问题的标准设计模式。它还将防范下面描述的问题。

11、ERC4626 的通胀攻击

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

但如果有人发送 99 个资产?它会向下取整为零,他们得到零股份。

当然没有人会故意扔掉他们的钱。但是,攻击者可以通过抢先交易向金库捐赠资产来进行攻击。

如果攻击者向金库捐赠资金,一个股份突然变得比最初更有价值。如果金库中有 10,000 个资产对应 100 个股份,而攻击者捐赠了 20,000 个资产,那么一个股份突然值 300 个资产而不是 100 个资产。当受害者的交易用资产换取股份时,他们突然得到的股份更少——可能是零。

有三种防御方式:

  • 如果收到的数量不在滑点容忍范围内,则回滚(如前所述)
  • 部署者应向池中存入足够多的资产,使得进行这种通胀攻击的成本太高
  • 向金库添加“虚拟流动性”,使定价行为类似于池子一开始就部署了足够的资产。

这里是 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,000×1,000÷1,000,000,即正好 1。

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

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

实际上,我们让分子变大了 1,000 倍。这迫使攻击者捐赠的金额是原来的 1,000 倍,从而阻止他们进行攻击。

12、股份/资产会计的实际例子

Compound 的早期版本向提供流动性的用户发行了他们所谓的 c-tokens。例如,如果你存入 USDC,你将获得一个单独的 cUSDC(Compound USDC)回来。当你决定停止借贷时,会将 cUSDC 发送回 compound(其中会被销毁),然后得到你的 USDC 借贷池的按比例份额。

Uniswap 使用 LP 代币作为“股份”来表示某人放入池子中的流动性(以及他们可以按比例赎回的金额),当他们用 LP 代币赎回底层资产时。


原文链接:ERC4626 Interface Explained

DefiPlot翻译整理,转载请标明出处

免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。