本文介绍了ERC-4626标准,它是ERC-20的扩展,为代币金库定义了一个标准接口。文章重点讨论了ERC-4626实施中的安全问题,特别是通货膨胀攻击,并提出了一种基于虚拟偏移的防御方法。此外,还讨论了如何在ERC-4626金库中添加费用的自定义行为,并提供了一个Solidity代码示例。
ERC-4626 是 ERC-20 的扩展,它为 token vault 提出一个标准接口。这个标准接口可以被应用到非常不同的合约(包括借贷市场、聚合器和内生带息的 token),这带来了一些微妙之处。理解这些潜在问题对于实现一个兼容且可组合的 token vault 至关重要。
我们提供了一个 ERC-4626 的基础实现,其中包含一个简单的 vault。此合约的设计方式允许开发者轻松地重新配置 vault 的行为,只需最少的重写,同时保持兼容性。在本指南中,我们将讨论一些影响 ERC-4626 的安全考虑因素。我们还将讨论 vault 的常见自定义方式。
为了交换存入 ERC-4626 vault 的资产,用户会收到 shares。这些 shares 可以在之后被销毁以赎回相应的底层资产。用户获得的 shares 数量取决于他们投入的资产数量以及 vault 的汇率。这个汇率由 vault 当前持有的流动性定义。
如果一个 vault 有 100 个 token 来支持 200 个 shares,那么每个 share 价值 0.5 个资产。
如果一个 vault 有 200 个 token 来支持 100 个 shares,那么每个 share 价值 2.0 个资产。
换句话说,汇率可以定义为穿过原点和 vault 中当前资产和 shares 数量的直线的斜率。存款和取款使 vault 沿着这条线移动。
当以对数比例绘制时,汇率的定义类似,但显示方式不同(因为 (0,0) 点无限远)。汇率由具有不同偏移量的“对角线”表示。
在这种表示中,非常不同的汇率可以在同一张图中清晰可见。在线性比例中则不会这样。
当存入 token 时,用户获得的 shares 数量会向下取整。这种取整会从用户那里夺走价值,从而有利于 vault(即有利于所有当前的 shareholders)。由于风险金额的原因,这种取整通常可以忽略不计。如果你存入价值 1e9 shares 的 token,取整最多会让你损失 0.0000001% 的存款。但是,如果你存入价值 10 shares 的 token,你可能会损失 10% 的存款。更糟糕的是,如果你存入价值 <1 share 的 token,那么你将获得 0 shares,并且你基本上是捐款了。
对于给定的资产数量,你收到的 shares 越多,你就越安全。如果你想将损失限制在最多 1%,则需要至少收到 100 个 shares。
在图中我们可以看到,对于给定的 500 资产存款,我们获得的 shares 数量以及相应的取整损失取决于汇率。如果汇率是橙色曲线的汇率,我们获得的 shares 少于一个,因此我们损失了 100% 的存款。但是,如果汇率是绿色曲线的汇率,我们将获得 5000 个 shares,这会将我们的取整损失限制在最多 0.02%。
对称地,如果我们专注于将损失限制在最多 0.5%,我们需要至少获得 200 个 shares。使用绿色汇率,这只需要 20 个 token,但使用橙色汇率,这需要 200000 个 token。
我们可以清楚地看到,蓝色和绿色曲线对应的 vault 比黄色和橙色曲线对应的 vault 更安全。
通货膨胀攻击的想法是,攻击者可以向 vault 捐赠资产以将汇率曲线向右移动,并使 vault 不安全。
图 6 显示了攻击者如何操纵空 vault 的汇率。首先,攻击者必须存入少量 token (1 token),然后直接向 vault 捐赠 1e5 个 token,以将汇率“右”移。这使 vault 处于这样一种状态,即任何小于 1e5 的存款都将完全损失到 vault 中。鉴于攻击者是唯一的 shareholder(通过捐赠),攻击者将窃取所有存入的 token。
攻击者通常会等待用户进行首次存款到 vault 中,然后会抢先执行上述攻击操作。风险很低,并且操纵 vault 所需的“捐赠”大小相当于被攻击的存款规模。
在数学上给出:
a0 攻击者存款
a1 攻击者捐赠
u 用户存款
资产 (Assets) | 份额 (Shares) | 比率 (Rate) | |
---|---|---|---|
初始 (initial) | 0 | 0 | - |
攻击者存款后 (after attacker’s deposit) | a0 | a0 | 1 |
攻击者捐赠后 (after attacker’s donation) | a0+a1 | a0 | a0a0+a1 |
这意味着 u 的存款将给出 u×a0a0+a1 shares。
为了让攻击者将存款稀释到 0 shares,导致用户损失所有存款,它必须确保
u×a0a0+a1<1⟺u<1+a1a0
使用 a0=1 和 a1=u 足够了。因此,攻击者只需要 u+1 资产即可执行成功的攻击。
很容易将上述结果推广到攻击者想要获取用户存款的一小部分的场景。为了瞄准 un,用户需要遭受类似比例的取整,这意味着用户必须最多收到 n 个 shares。这导致:
u×a0a0+a1<n⟺un<1+a1a0
在这种情况下,攻击的威力(在窃取多少方面)降低了 n 倍,并且执行成本降低了 n 倍。在这两种情况下,攻击者需要投入的资金量都相当于其潜在收益。
我们提出的防御基于 YieldBox 中使用的方法。它由两部分组成:
使用 shares 和资产的表示“精度”之间的偏移量。换句话说,我们使用比底层 token 用于表示资产的小数位数更多的小数位数来表示 shares。
在汇率计算中包括虚拟 shares 和虚拟资产。这些虚拟资产在 vault 为空时强制执行转换率。
这两个部分共同作用以强制执行 vault 的安全性。首先,精度提高对应于高汇率,我们看到它更安全,因为它减少了计算 shares 数量时的取整误差。其次,虚拟资产和 shares(除了简化大量计算外)还捕获了部分捐赠,使得开发者执行攻击无利可图。
按照之前的数学定义,我们有:
δ vault 偏移量
a0 攻击者存款
a1 攻击者捐赠
u 用户存款
资产 (Assets) | 份额 (Shares) | 比率 (Rate) | |
---|---|---|---|
初始 (initial) | 1 | 10δ | 10δ |
攻击者存款后 (after attacker’s deposit) | 1+a0 | 10δ×(1+a0) | 10δ |
攻击者捐赠后 (after attacker’s donation) | 1+a0+a1 | 10δ×(1+a0) | 10δ×1+a01+a0+a1 |
一个需要注意的重要事项是,攻击者只拥有 shares 的一部分 a01+a0,因此在进行捐赠时,他只能收回捐赠的那部分 a1×a01+a0。剩下的 a11+a0 由 vault 捕获。
损失(loss)=a11+a0
当用户存入 u 时,他会收到
10δ×u×1+a01+a0+a1
为了让攻击者将存款稀释到 0 shares,导致用户损失所有存款,它必须确保
10δ×u×1+a01+a0+a1<1
⟺10δ×u<1+a0+a11+a0
⟺10δ×u<1+a11+a0
⟺10δ×u≤loss
如果偏移量为 0,则攻击者的损失至少等于用户的存款。
如果偏移量大于 0,则攻击者将不得不遭受比可能从用户那里窃取的价值量大几个数量级的损失。
这表明,即使偏移量为 0,虚拟 shares 和资产也使这种攻击对攻击者来说无利可图。更大的偏移量通过使对用户的任何攻击都非常浪费来进一步提高安全性。
下图显示了偏移量如何影响初始汇率并限制资金有限的攻击者有效提高汇率的能力。
δ=3, a0=1, a1=105
δ=3, a0=100, a1=105
δ=6, a0=1, a1=105
在 ERC-4626 vault 中,可以在存款/铸币和/或取款/赎回步骤中收取费用。在这两种情况下,必须保持符合 ERC-4626 关于预览函数的准则。
例如,如果调用 deposit(100, receiver)
,调用者应存入正好 100 个底层 token,包括费用,并且接收者应收到与 previewDeposit(100)
返回的值相匹配的 shares 数量。类似地,previewMint
应考虑用户必须在 share 成本之上支付的费用。
至于 Deposit
事件,虽然这在 EIP 规范本身中不太清楚,但似乎达成共识的是,它应包括用户支付的资产数量,包括费用。
另一方面,当提取资产时,用户给出的数字应对应于他收到的数字。任何费用都应添加到 previewWithdraw
执行的报价(以 shares 为单位)中。
Withdraw
事件应包括用户销毁的 shares 数量(包括费用)和用户实际收到的资产数量(扣除费用后)。
这种设计的结果是 Deposit
和 Withdraw
事件都将描述两种汇率。 “买入”和“退出”价格之间的差额对应于 vault 收取的费用。
以下示例描述了如何实现与存款/取款金额成比例的费用:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
/// @dev ERC-4626 vault,具有以 https://en.wikipedia.org/wiki/Basis_point[基点 (bp)] 表示的进入/退出费。
///
/// 注意:该合约以资产而非 shares 计算费用。 这意味着费用根据被存入或提取的资产数量计算,而不是根据
/// 正在铸造或赎回的 shares 数量计算。 这是一个主观的设计决策,在集成此合约时应考虑到这一点。
///
/// 警告:此合约尚未经过审计,不应被视为生产就绪。 考虑谨慎使用它。
abstract contract ERC4626Fees is ERC4626 {
using Math for uint256;
uint256 private constant _BASIS_POINT_SCALE = 1e4;
// === 重写 (Overrides) ===
/// @dev 预览在存款时收取入场费。 请参见 {IERC4626-previewDeposit}。
function previewDeposit(uint256 assets) public view virtual override returns (uint256) {
uint256 fee = _feeOnTotal(assets, _entryFeeBasisPoints());
return super.previewDeposit(assets - fee);
}
/// @dev 预览在铸币时添加入场费。 请参见 {IERC4626-previewMint}。
function previewMint(uint256 shares) public view virtual override returns (uint256) {
uint256 assets = super.previewMint(shares);
return assets + _feeOnRaw(assets, _entryFeeBasisPoints());
}
/// @dev 预览在取款时添加退场费。 请参见 {IERC4626-previewWithdraw}。
function previewWithdraw(uint256 assets) public view virtual override returns (uint256) {
uint256 fee = _feeOnRaw(assets, _exitFeeBasisPoints());
return super.previewWithdraw(assets + fee);
}
/// @dev 预览在赎回时收取退场费。 请参见 {IERC4626-previewRedeem}。
function previewRedeem(uint256 shares) public view virtual override returns (uint256) {
uint256 assets = super.previewRedeem(shares);
return assets - _feeOnTotal(assets, _exitFeeBasisPoints());
}
/// @dev 将入场费发送给 {_entryFeeRecipient}。 请参见 {IERC4626-_deposit}。
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override {
uint256 fee = _feeOnTotal(assets, _entryFeeBasisPoints());
address recipient = _entryFeeRecipient();
super._deposit(caller, receiver, assets, shares);
if (fee > 0 && recipient != address(this)) {
SafeERC20.safeTransfer(IERC20(asset()), recipient, fee);
}
}
/// @dev 将退场费发送给 {_exitFeeRecipient}。 请参见 {IERC4626-_deposit}。
function _withdraw(
address caller,
address receiver,
address owner,
uint256 assets,
uint256 shares
) internal virtual override {
uint256 fee = _feeOnRaw(assets, _exitFeeBasisPoints());
address recipient = _exitFeeRecipient();
super._withdraw(caller, receiver, owner, assets, shares);
if (fee > 0 && recipient != address(this)) {
SafeERC20.safeTransfer(IERC20(asset()), recipient, fee);
}
}
// === 费用配置 (Fee configuration) ===
function _entryFeeBasisPoints() internal view virtual returns (uint256) {
return 0; // 替换为 例如 100 表示 1%
}
function _exitFeeBasisPoints() internal view virtual returns (uint256) {
return 0; // 替换为 例如 100 表示 1%
}
function _entryFeeRecipient() internal view virtual returns (address) {
return address(0); // 替换为 例如 金库地址
}
function _exitFeeRecipient() internal view virtual returns (address) {
return address(0); // 替换为 例如 金库地址
}
// === 费用计算 (Fee operations) ===
/// @dev 计算应添加到尚未包含费用的金额 `assets` 的费用。
/// 在 {IERC4626-mint} 和 {IERC4626-withdraw} 操作中使用。
function _feeOnRaw(uint256 assets, uint256 feeBasisPoints) private pure returns (uint256) {
return assets.mulDiv(feeBasisPoints, _BASIS_POINT_SCALE, Math.Rounding.Ceil);
}
/// @dev 计算已包含费用的金额 `assets` 的费用部分。
/// 在 {IERC4626-deposit} 和 {IERC4626-redeem} 操作中使用。
function _feeOnTotal(uint256 assets, uint256 feeBasisPoints) private pure returns (uint256) {
return assets.mulDiv(feeBasisPoints, feeBasisPoints + _BASIS_POINT_SCALE, Math.Rounding.Ceil);
}
}
- 原文链接: docs.openzeppelin.com/co...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!