深入了解最小代理合约
- 原文链接:blog.openzeppelin.com/deep-dive-in...
- 译者:AI翻译官,校对:翻译小组
- 本文链接:learnblockchain.cn/article…
这段内容最初由 Martin Abbatemarco 创作
为了讨论 EIP 1167: 最小代理合约,我的方法将与你预期的不同。挑战在于 从头开始构建一个最小代理,而不涉及任何 Solidity 代码。在这个过程中,我们将学习许多 EVM 指令的工作原理,并希望再也不会被这串丑陋的字节序列吓到:
🔥🔥🔥🔥🔥
3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3
🔥🔥🔥🔥🔥
假设你需要为每个用户部署一个钱包,或者你需要为每个交易操作设置一个托管。
这些只是你需要多次部署相同合约的示例。初始化数据当然可能对每个单独的合约有所不同,但代码是相同的。
由于部署大型合约可能非常昂贵,因此有一个巧妙的解决方法,你可以以 最小 的部署成本部署相同的合约数千次:这就是 EIP 1167,但我们称之为 最小代理。
社区中的许多人仍然觉得这个 EIP 相当晦涩、可怕,或者根本无法理解。这是有道理的,因为它是用令人畏惧的 EVM 低级代码编写的。因此,本文的目的是从头开始覆盖该标准,并为其提供足够的光照,以使社区摆脱恐惧,实现世界和平 🌈。
回顾一下,EIP 背后的理由是,与其多次部署一个 庞大 的合约,不如只部署一个超级便宜的 最小 代理合约,该合约指向链上已经存在的庞大合约。
最小意味着 最小。也就是说,代理合约所做的只是 委托 所有调用到实现合约——没有更多,没有更少。确保你 不要将 EIP 1167 最小代理合约与 代理模式 混淆,用于合约升级。
EIP 1167 与可升级性无关,也不试图取代它。
首先,让我们想一想最小代理需要做什么:
就这样!所以让我们尝试 非常粗略 地将这四个步骤映射到 EVM 指令中:
我们想要的 | EVM 指令 | 简短说明 |
---|---|---|
解析来自调用者的数据 | CALLDATACOPY | calldata 是在交易中发送到合约的数据,因此我们需要将其复制到内存中,以便稍后能够转发。 |
执行对实现合约的 DELEGATECALL | DELEGATECALL | 这没有什么意外。在这里,我们将转发获得的 calldata 到实现合约。 |
检索外部调用的结果 | RETURNDATACOPY | 我们将 DELEGATECALL 返回的数据复制到内存中。 |
将数据返回给调用者或撤销交易 | JUMPI, RETURN, REVERT | 根据外部调用的成功/失败状态,我们要么返回数据,要么撤销交易。 |
好吧,这并不是那么难,对吧?我们已经走了一半的路!我们只需要解决一些小的实现细节。
记住这 4 个步骤。获取 calldata,委托调用,获取返回的数据,然后返回或撤销。简单易行。
让我们假设我们从一个空栈和干净的内存开始。
# | 参数 | 我们将传递的内容 |
---|---|---|
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 已在内存中。
# | 参数 | 我们将传递的内容 |
---|---|---|
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。
根据 DELEGATECALL 推入栈中的项目,我们可以判断调用是否成功。但是如果外部调用返回了一些数据,比如错误消息或函数的返回值呢?
EVM 为我们提供了一条特定的指令,帮助我们检索这些数据。
# | 参数 | 我们将传递的内容 |
---|---|---|
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 返回的数据复制到内存中。请注意,我们在栈中留下了两个项目,我们将在最后阶段使用它们。
我们收到了数据,然后执行了 DELEGATECALL,最后将返回的数据复制到内存中。是时候做出重大最终决定:我们应该返回还是回滚?
这完全取决于我们在栈中拥有的 success 项是否为 0。
在 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 指令,这些指令将返回并将此运行时代码放入区块链中。幸运的是,这相当简单:
我们已经在上一节中构建了我们的运行时代码。
现在,我们的工作是将一组指令组合在一起,将那长串字节放入内存中。不出所料,EVM 为此提供了一条指令:
# | 参数 | 我们将传递的内容 |
---|---|---|
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 之后栈上留下的 2d
和 0
是什么呢?那是即将到来的 RETURN 指令的参数,它需要 0
和 2d
作为参数。
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 与我一起冒险并帮助我审阅这篇文章
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!