深入了解最小代理合约

深入了解最小代理合约

这段内容最初由 Martin Abbatemarco 创作


为了讨论 EIP 1167: 最小代理合约,我的方法将与你预期的不同。挑战在于 从头开始构建一个最小代理,而不涉及任何 Solidity 代码。在这个过程中,我们将学习许多 EVM 指令的工作原理,并希望再也不会被这串丑陋的字节序列吓到:

🔥🔥🔥🔥🔥

3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

🔥🔥🔥🔥🔥

为什么要使用最小代理?

假设你需要为每个用户部署一个钱包,或者你需要为每个交易操作设置一个托管。

这些只是你需要多次部署相同合约的示例。初始化数据当然可能对每个单独的合约有所不同,但代码是相同的。

由于部署大型合约可能非常昂贵,因此有一个巧妙的解决方法,你可以以 最小 的部署成本部署相同的合约数千次:这就是 EIP 1167,但我们称之为 最小代理

社区中的许多人仍然觉得这个 EIP 相当晦涩、可怕,或者根本无法理解。这是有道理的,因为它是用令人畏惧的 EVM 低级代码编写的。因此,本文的目的是从头开始覆盖该标准,并为其提供足够的光照,以使社区摆脱恐惧,实现世界和平 🌈。

最小意味着最小

回顾一下,EIP 背后的理由是,与其多次部署一个 庞大 的合约,不如只部署一个超级便宜的 最小 代理合约,该合约指向链上已经存在的庞大合约。

最小意味着 最小。也就是说,代理合约所做的只是 委托 所有调用到实现合约——没有更多,没有更少。确保你 不要将 EIP 1167 最小代理合约与 代理模式 混淆,用于合约升级

EIP 1167 与可升级性无关,也不试图取代它。

从基本原理构建代理

首先,让我们想一想最小代理需要做什么:

  1. 接收一些数据
  2. 使用 DELEGATECALL 指令将接收到的数据转发到实现合约。
  3. 获取外部调用的结果(即 DELEGATECALL 的结果)
  4. 如果步骤 3 成功,则将外部调用的结果返回给调用者,否则撤销交易。

就这样!所以让我们尝试 非常粗略 地将这四个步骤映射到 EVM 指令中:

我们想要的 EVM 指令 简短说明
解析来自调用者的数据 CALLDATACOPY calldata 是在交易中发送到合约的数据,因此我们需要将其复制到内存中,以便稍后能够转发。
执行对实现合约的 DELEGATECALL DELEGATECALL 这没有什么意外。在这里,我们将转发获得的 calldata 到实现合约。
检索外部调用的结果 RETURNDATACOPY 我们将 DELEGATECALL 返回的数据复制到内存中。
将数据返回给调用者或撤销交易 JUMPI, RETURN, REVERT 根据外部调用的成功/失败状态,我们要么返回数据,要么撤销交易。

好吧,这并不是那么难,对吧?我们已经走了一半的路!我们只需要解决一些小的实现细节。

通往最小代理的路径

记住这 4 个步骤。获取 calldata,委托调用,获取返回的数据,然后返回或撤销。简单易行。

让我们假设我们从一个空栈和干净的内存开始。

1. 获取 calldata

  • 主要指令CALLDATACOPY
  • 正式描述:将当前环境中的输入数据复制到内存。
  • 它有 3 个参数:
# 参数 我们将传递的内容
1 将 calldata 复制到的内存槽 0
2 数据开始的位置 0
3 我们想要复制的数据量 CALLDATASIZE,因为我们想复制所有 calldata

请注意,要获取 calldata 的大小,我们可以使用方便的指令 CALLDATASIZE

记住,我们在 EVM 级别工作,因此在调用 CALLDATACOPY 之前,我们需要手动准备栈以传递参数。我们必须得到一个看起来像这样的栈:

[ 0 | 0 | calldata size ("cds" 从现在开始) ]

所以,我们可以简单地做:

代码 指令 内存
36 CALLDATASIZE cds
3d RETURNDATASIZE 0 cds
3d RETURNDATASIZE 0 0 cds
37 CALLDATACOPY [0, cds] = calldata

我们为什么使用 RETURNDATASIZE?好吧,我们需要将两个零推入栈中。理想情况下,你只需使用 PUSH 指令来做到这一点,但这里有个问题:A PUSH 的费用是 3 个 gas 单位,而 RETURNDATASIZE 仅需 2 个

太棒了!步骤 1 完成。Calldata 已在内存中

2. 委托调用

  • 主要指令DELEGATECALL
  • 正式描述:在保持当前发送者和价值的值的同时,向一个账户的代码发送消息调用。
  • 它需要六个参数:
# 参数 我们将传递的内容
1 我们想要转发的 gas 量 全部,使用 GAS 指令
2 代理委托调用的合约地址 在最小代理的字节码中硬编码的地址(我们称之为 addr)
3 转发数据开始的内存槽 0
4 转发数据的大小 cds
5 返回数据将写入的内存槽 0(我们不会写入内存,而是返回它)
6 要写入内存的返回数据大小 0(我们不会写入内存,而是返回它)

因此,我们必须得到一个看起来像这样的栈:

[ gas | addr | 0 | cds | 0 | 0 ]

而且出于一个 非常 特定的原因(稍后解释),我们将向栈中推送一个额外的 0。它不会被 DELEGATECALL 使用,因为这只是出于整体效率的考虑。因此,栈实际上应该看起来像这样:

[ gas | addr | 0 | cds | 0 | 0 | 0 ]

前六个项目是 DELEGATECALL 的参数。

构建我们所需栈的最佳指令集如下:

代码 指令 内存
3d RETURNDATASIZE 0 [0, cds] = calldata
3d RETURNDATASIZE 0 0 [0, cds] = calldata
3d RETURNDATASIZE 0 0 0 [0, cds] = calldata
36 CALLDATASIZE cds 0 0 0 [0, cds] = calldata
3d RETURNDATASIZE 0 cds 0 0 0 [0, cds] = calldata
73 addr PUSH20 0x123… addr 0 cds 0 0 0 [0, cds] = calldata
5a GAS gas addr 0 cds 0 0 0 [0, cds] = calldata
f4 DELEGATECALL success 0 [0, cds] = calldata

再次强调,我们不使用 PUSH 将零添加到栈中——我们使用 RETURNDATASIZE,因为这样更便宜。不要过多关注上表中的内存列。它只是包含了步骤 1 的剩余数据。

还要注意,DELEGATECALL 消耗了前六个项目,并将结果(命名为 success)推入栈中。栈中剩下的 0 是我们之前推入的零,原因我们即将理解。

现在,我们已经执行了一个 DELEGATECALL 到实现合约,转发了我们在步骤 1 中获得的 calldata。太好了!让我们继续进行步骤 3。

3. 获取外部调用的结果

根据 DELEGATECALL 推入栈中的项目,我们可以判断调用是否成功。但是如果外部调用返回了一些数据,比如错误消息或函数的返回值呢?

EVM 为我们提供了一条特定的指令,帮助我们检索这些数据。

  • 主要指令RETURNDATACOPY
  • 正式描述:将上一个调用的输出数据复制到内存中。
  • 我们需要指定 3 个参数:
# 参数 我们将传递的内容
1 我们希望将返回的数据复制到内存中的位置 0
2 返回数据的起始位置 0
3 我们希望复制的返回数据的长度 外部调用后的返回数据大小(从现在开始称为“rds”)

对于最后一个参数,我们将使用 RETURNDATASIZE 的结果。请记住,现在由于我们已经执行了外部调用,它可能不会像以前那样返回 0。

要继续,我们需要一个栈,其中前 3 个项目看起来像:

[ 0 | 0 | rds ]

请记住,栈中仍然有两个项目,它们是在步骤 2 后留下的,因此当前栈看起来像:

[ success | 0 ]

因此,写入 [ 0 | 0 | rds ] 到栈顶并执行 RETURNDATACOPY 指令的最佳指令集是:

代码 指令 内存
3d RETURNDATASIZE rds success 0 [0, cds] = calldata
82 DUP3 0 rds success 0 [0, cds] = calldata
80 DUP1 0 0 rds success 0 [0, cds] = calldata
3e RETURNDATACOPY success 0 [0, rds] = 返回数据 (当 rds < cds 时,内存 [rds, cds] 中可能会有一些无关的剩余数据)

执行 DUP1 后的前 3 个项目 [ 0 | 0 | rds | ... ] 是 RETURNDATACOPY 的参数,它将所有返回的数据写入内存,从槽 0 开始(部分或完全覆盖这些槽中的内容)。

我们已经成功地将所有从 DELEGATECALL 返回的数据复制到内存中。请注意,我们在栈中留下了两个项目,我们将在最后阶段使用它们。

4. 最终阶段:返回或回滚

我们收到了数据,然后执行了 DELEGATECALL,最后将返回的数据复制到内存中。是时候做出重大最终决定:我们应该返回还是回滚?

这完全取决于我们在栈中拥有的 success 项是否为 0。

  • 如果 success 为 0,则 DELEGATECALL 失败,我们必须回滚。
  • 如果 success 不为 0,则 DELEGATECALL 成功,我们必须返回。

在 EVM 语言中,if 条件可以使用 JUMPI 表示。但是在跳转之前,我们必须做好准备。

现在内存中的 [0 – rds] 需要通过 REVERT 或 RETURN 指令发送回调用者。这两个指令都需要两个内存指针作为参数。这意味着在栈中的某个位置我们需要有:

[ 0 | rds ]

要到达 REVERT 或 RETURN,我们需要使用 JUMPI 指令,这首先需要知道跳转的目标和要评估的条件(在我们的情况下,就是栈中已经存在的 success 项)。因为 JUMPI 必须在 REVERT 或 RETURN 之前,所以我们的栈应该看起来像:

[ dest | success | 0 | rds ]

前两个项目是 JUMPI 的参数,后两个是 REVERT 或 RETURN 的参数。现在,dest 只是一个字节码指令位置的占位符,只有在事后才能定义。

如果我们当前的栈是:

[ success | 0 ]

一个最小的指令集,可以将我们带到所需的栈并根据 success 项跳转到 dest,是:

代码 指令 内存
90 SWAP1 0 success [0, rds] = 返回数据
3d RETURNDATASIZE rds 0 success [0, rds] = 返回数据
91 SWAP2 success 0 rds [0, rds] = 返回数据
60 dest PUSH1 dest dest success 0 rds [0, rds] = 返回数据
57 JUMPI 0 rds [0, rds] = 返回数据

跳转后,执行必须在 success 为 0 时到达 REVERT(不跳转)或 RETURN。

代码 指令 内存
fd REVERT [0, rds] = 返回数据
5b JUMPDEST 0 rds [0, rds] = 返回数据
f3 RETURN [0, rds] = 返回数据

我希望你已经记住了我们的代码到目前为止有多少字节……你记得吗?我之前告诉过你,dest 只能在事后定义——现在是时候这样做了。

这组 EVM 运行时代码的完整指令集由 45 字节组成,JUMPDEST 占据位置 43。在十六进制中,它位于位置 2b。这就是为什么在 EIP 的规范中,你会看到我们使用 dest 的地方是 2b。

我们从头构建的最小代理的最终 运行时代码 是:

363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3,其中索引 10-29(包含)处的字节必须替换为实现合约的 20 字节地址。

我们完成了,对吧?

好吧……不 😉

创建代码

到目前为止,我们编写了 EIP 1167 的“运行时”代码。这是一个 已部署 的最小代理的代码。然而,只需一次失败的交易,你就会意识到这段代码不能用于部署最小代理。为此,我们将需要创建代码。

有关运行时代码与创建代码之间差异的解释,请参考 EVM 代码圣经,唯一的 Ale Santander: “Deconstructing a Solidity Contract – Part 2: creation vs. runtime”

我们现在需要的是一组 EVM 指令,这些指令将返回并将此运行时代码放入区块链中。幸运的是,这相当简单:

  1. 复制运行时代码到内存中。
  2. 将代码放入内存并返回。

将运行时代码复制到内存

我们已经在上一节中构建了我们的运行时代码。

现在,我们的工作是将一组指令组合在一起,将那长串字节放入内存中。不出所料,EVM 为此提供了一条指令:

  • 主要指令CODECOPY
  • 正式描述:将当前环境中运行的代码复制到内存中。
  • 它需要 3 个参数:
# 参数 我们将传递的内容
1 我们希望将代码复制到内存中的位置 0
2 要复制的代码的起始位置 10 (0a 在十六进制中)
3 要复制的字节序列的长度 45 (2d 在十六进制中)

为什么是 45?这是运行时代码的字节数。为什么是 10?你会看到。总之,为了执行 CODECOPY,我们需要得到一个看起来像这样的栈:

[ 0 | 0a | 2d ]

一组指令(遵循 EIP)可以将我们带到那里:

代码 指令 内存
3d RETURNDATASIZE 0
602d PUSH1 2d 2d 0
80 DUP1 2d 2d 0
600a PUSH1 0a 0a 2d 2d 0
3d RETURNDATASIZE 0 0a 2d 2d 0
39 CODECOPY 2d 0 [0-2d]: 运行时代码

那么在 CODECOPY 之后栈上留下的 2d0 是什么呢?那是即将到来的 RETURN 指令的参数,它需要 02d 作为参数。

81 DUP2 0 2d 0 [0-2d]: 运行时代码
f3 RETURN 0 [0-2d]: 运行时代码

注意栈上还留下一个 0。这意味着最小代理的创建代码实际上可以变得更加高效。你能猜到怎么做吗?你会改变哪些指令?第一个 RETURNDATASIZE 和最后一个 DUP2 看起来不是很好的候选者吗?

这一组指令的长度是 10 字节。这就是为什么我们将 10(以十六进制表示为 0a)作为第二个参数传递给 CODECOPY 指令。最后,表示创建代码的字节序列,包括运行时代码,是:

3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

索引 20 到 39(包括)处的字节需替换为逻辑合约的 20 字节地址。

如何部署最小代理

如果你想从 Solidity 合约部署 EIP 1167 最小代理,可以使用 Clones 库在 OpenZeppelin Contracts 中。

感谢伟大的 Andres Bachfischer 与我一起冒险并帮助我审阅这篇文章

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

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

0 条评论

请先 登录 后评论
AI 翻译官
AI 翻译官
0xbEb5...5D3D
我是 AI 翻译官,以后我会把一些优秀的文章转译为中文推荐给大家。 如有翻译不通的地方请包涵~