ERC-4626

ERC-4626ERC-20 的一个扩展,它提出了 token vault 的一个标准接口。这个标准接口可以被各种不同的合约使用(包括借贷市场、聚合器和内生型计息 token),这带来了一些微妙之处。为了实现兼容且可组合的 token vault,理解这些潜在的问题至关重要。

我们提供了一个 ERC-4626 的基础实现,其中包含一个简单的 vault。这个合约的设计方式允许开发者轻松地重新配置 vault 的行为,只需最少的重写,同时保持兼容性。在本指南中,我们将讨论一些影响 ERC-4626 的安全考虑因素。我们还将讨论 vault 的常见自定义。

安全问题:通货膨胀攻击

可视化 vault

为了换取存入 ERC-4626 vault 的资产,用户会收到份额(shares)。这些份额可以在之后被销毁以赎回相应的底层资产。用户获得的份额数量取决于他们投入的资产数量以及 vault 的汇率。这个汇率由 vault 当前持有的流动性来定义。

  • 如果一个 vault 有 100 个 token 来支持 200 份份额,那么每份份额价值 0.5 个资产。

  • 如果一个 vault 有 200 个 token 来支持 100 份份额,那么每份份额价值 2.0 个资产。

换句话说,汇率可以定义为穿过原点以及 vault 中当前资产和份额数量的直线的斜率。存款和取款操作会使 vault 在这条线上移动。

线性比例下的汇率

当以对数比例绘制时,汇率的定义类似,但显示效果不同(因为点 (0,0) 在无穷远处)。汇率用具有不同偏移的“对角线”表示。

对数比例下的汇率

在这样的表示中,差异很大的汇率可以在同一张图中清晰可见。在线性比例中则不会出现这种情况。

更多对数比例下的汇率

攻击

当存入 token 时,用户获得的份额数量会向下取整。这种取整会从用户那里夺走价值,从而有利于 vault(即有利于所有当前的股东)。由于风险的数量,这种取整通常可以忽略不计。 如果你存入价值 1e9 份额的 token,那么取整最多会让你损失 0.0000001% 的存款。但是,如果你存入价值 10 份额的 token,你可能会损失 10% 的存款。更糟糕的是,如果你存入价值 <1 份额的 token,那么你会得到 0 份额,并且你基本上进行了捐赠。

对于给定数量的资产,你收到的份额越多就越安全。如果你想将损失限制在最多 1%,则需要至少收到 100 份份额。

存入资产

在图中我们可以看到,对于给定的 500 资产的存款,我们获得的份额数量和相应的取整损失取决于汇率。如果汇率是橙色曲线的汇率,我们会获得小于一份份额,因此我们会损失 100% 的存款。但是,如果汇率是绿色曲线的汇率,我们会获得 5000 份份额,这样我们的取整损失最多限制在 0.02%。

铸造份额

对称地,如果我们专注于将损失限制在最多 0.5%,我们需要至少获得 200 份份额。使用绿色汇率只需要 20 个 token,但是使用橙色汇率需要 200000 个 token。

我们可以清楚地看到蓝色和绿色曲线对应于比黄色和橙色曲线更安全的 vault。

通货膨胀攻击的想法是,攻击者可以向 vault 捐赠资产以将汇率曲线向右移动,并使 vault 不安全。

没有保护的通货膨胀攻击

图 6 说明了攻击者如何操纵一个空 vault 的汇率。首先,攻击者必须存入少量 token(1 个token),然后直接向该 vault 捐赠 1e5 个 token,以使汇率“向右”移动。 这将使 vault 处于一种状态,即任何小于 1e5 的存款都会完全损失给 vault。 鉴于攻击者是唯一的股东(通过他们的捐赠),攻击者将窃取所有存入的 token。

攻击者通常会等待用户进行首次存款到 vault 中,并抢先执行上述攻击。 风险很低,并且操纵 vault 所需的“捐赠”金额等于被攻击存款的金额。

用数学公式表示为:

  • \(a_0\) 攻击者存款

  • \(a_1\) 攻击者捐赠

  • \(u\) 用户存款

资产 份额 汇率

初始

\(0\)

\(0\)

-

攻击者存款后

\(a_0\)

\(a_0\)

\(1\)

攻击者捐赠后

\(a_0+a_1\)

\(a_0\)

\(\frac{a_0}{a_0+a_1}\)

这意味着 \(u\) 的存款将给出 \(\frac{u \times a_0}{a_0 + a_1}\) 份额。

为了让攻击者将该存款稀释到 0 份额,导致用户损失所有存款,它必须确保

\[\frac{u \times a_0}{a_0+a_1} < 1 \iff u < 1 + \frac{a_1}{a_0}\]

使用 \(a_0 = 1\) 和 \(a_1 = u\) 就足够了。 因此,攻击者只需要 \(u+1\) 资产就可以成功发动攻击。

很容易将上述结果推广到攻击者试图获得用户存款的较小部分的情况。 为了瞄准 \(\frac{u}{n}\),用户需要遭受类似比例的舍入,这意味着用户最多必须收到 \(n\) 份额。 这导致:

\[\frac{u \times a_0}{a_0+a_1} < n \iff \frac{u}{n} < 1 + \frac{a_1}{a_0}\]

在这种情况下,攻击的威力(在窃取金额方面)要小 \(n\) 倍,并且执行成本要低 \(n\) 倍。 在这两种情况下,攻击者需要投入的资金量都相当于其潜在收益。

使用虚拟偏移进行防御

我们提出的防御基于 YieldBox 中使用的方法。 它由两部分组成:

  • 使用份额和资产表示的“精度”之间的偏移量。 也就是说,我们使用比底层 token 表示资产更多的小数位来表示份额。

  • 在汇率计算中包含虚拟份额和虚拟资产。 这些虚拟资产在 vault 为空时强制执行转换率。

这两个部分一起工作以确保 vault 的安全性。 首先,增加的精度对应于较高的汇率,我们已经看到这更安全,因为它减少了计算份额数量时的舍入误差。 其次,虚拟资产和份额(除了简化大量计算之外)捕获了部分捐赠,使得开发者进行攻击无利可图。

按照之前的数学定义,我们有:

  • \(\delta\) vault 偏移量

  • \(a_0\) 攻击者存款

  • \(a_1\) 攻击者捐赠

  • \(u\) 用户存款

资产 份额 汇率

初始

\(1\)

\(10^\delta\)

\(10^\delta\)

攻击者存款后

\(1+a_0\)

\(10^\delta \times (1+a_0)\)

\(10^\delta\)

攻击者捐赠后

\(1+a_0+a_1\)

\(10^\delta \times (1+a_0)\)

\(10^\delta \times \frac{1+a_0}{1+a_0+a_1}\)

需要注意的一个重要的事情是,攻击者只拥有份额的 \(\frac{a_0}{1 + a_0}\) 部分,因此在进行捐赠时,他只能收回捐赠额 \(\frac{a_1 \times a_0}{1 + a_0}\) 的一部分。 剩余的 \(\frac{a_1}{1+a_0}\) 由 vault 捕获。

\[\mathit{loss} = \frac{a_1}{1+a_0}\]

当用户存入 \(u\) 时,他收到

\[10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1}\]

为了让攻击者将该存款稀释到 0 份额,导致用户损失所有存款,它必须确保

\[10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1} < 1\]
\[\iff 10^\delta \times u < \frac{1+a_0+a_1}{1+a_0}\]
\[\iff 10^\delta \times u < 1 + \frac{a_1}{1+a_0}\]
\[\iff 10^\delta \times u \le \mathit{loss}\]
  • 如果偏移量为 0,则攻击者的损失至少等于用户的存款。

  • 如果偏移量大于 0,则攻击者将不得不遭受比理论上可以从用户那里窃取的价值大几个数量级的损失。

这表明,即使偏移量为 0,虚拟份额和资产也使这种攻击对攻击者来说无利可图。 更大的偏移量通过使用户遭受的任何攻击都非常浪费来进一步提高安全性。

下图显示了偏移量如何影响初始汇率,并限制了资金有限的攻击者有效膨胀汇率的能力。

erc4626 attack 3a

\(\delta = 3\), \(a_0 = 1\), \(a_1 = 10^5\)

erc4626 attack 3b

\(\delta = 3\), \(a_0 = 100\), \(a_1 = 10^5\)

erc4626 attack 6

\(\delta = 6\), \(a_0 = 1\), \(a_1 = 10^5\)

自定义行为:向 vault 添加费用

在 ERC-4626 vault 中,可以在存款/铸造和/或取款/赎回步骤中收取费用。 在这两种情况下,都必须遵守有关预览函数的 ERC-4626 要求。

例如,如果调用 deposit(100, receiver),调用者应存入正好 100 个底层 token,包括费用,并且接收者应收到与 previewDeposit(100) 返回的值匹配的份额数量。 同样,previewMint 应该考虑用户必须在份额成本之上支付的费用。

至于 Deposit 事件,虽然这在 EIP 规范本身中不太明确,但似乎已达成共识,它应该包括用户支付的资产数量,包括费用。

另一方面,当提取资产时,用户给出的数字应与他收到的数量相对应。 任何费用都应添加到 previewWithdraw 执行的报价(以份额表示)中。

Withdraw 事件应包括用户燃烧的份额数量(包括费用)和用户实际收到的资产数量(扣除费用后)。

这种设计的结果是 DepositWithdraw 事件都将描述两种汇率。 “买入”价格和“退出”价格之间的差额对应于 vault 收取的费用。

以下示例描述了如何实现与存款/取款金额成比例的费用:

Unresolved include directive in modules/ROOT/pages/erc4626.adoc - include::api:example$ERC4626Fees.sol[]