{"content":{"title":"深入了解最小代理合约","body":">- 原文链接：[blog.openzeppelin.com/deep-dive-in...](https://blog.openzeppelin.com/deep-dive-into-the-minimal-proxy-contract)\r\n>- 译者：[AI翻译官](https://learnblockchain.cn/people/19584)，校对：[翻译小组](https://learnblockchain.cn/people/412)\r\n>- 本文链接：[learnblockchain.cn/article…](https://learnblockchain.cn/article/10190)\r\n    \r\n_**这段内容最初由 [Martin Abbatemarco](http://twitter.com/tinchoabbate) 创作**_\r\n\r\n* * *\r\n\r\n为了讨论 [EIP 1167: 最小代理合约](https://eips.ethereum.org/EIPS/eip-1167)，我的方法将与你预期的不同。挑战在于 **从头开始构建一个最小代理，而不涉及任何 Solidity 代码**。在这个过程中，我们将学习许多 EVM 指令的工作原理，并希望再也不会被这串丑陋的字节序列吓到：\r\n\r\n**🔥🔥🔥🔥🔥**\r\n\r\n3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3\r\n\r\n**🔥🔥🔥🔥🔥**\r\n\r\n## 为什么要使用最小代理？\r\n\r\n假设你需要为每个用户部署一个钱包，或者你需要为每个交易操作设置一个托管。\r\n\r\n这些只是你需要多次部署相同合约的示例。初始化数据当然可能对每个单独的合约有所不同，但代码是相同的。\r\n\r\n由于部署大型合约可能非常昂贵，因此有一个巧妙的解决方法，你可以以 _最小_ 的部署成本部署相同的合约数千次：这就是 [EIP 1167](https://eips.ethereum.org/EIPS/eip-1167)，但我们称之为 **最小代理**。\r\n\r\n社区中的许多人仍然觉得这个 EIP 相当晦涩、可怕，或者根本无法理解。这是有道理的，因为它是用令人畏惧的 EVM 低级代码编写的。因此，本文的目的是从头开始覆盖该标准，并为其提供足够的光照，以使社区摆脱恐惧，实现世界和平 🌈。\r\n\r\n## 最小意味着最小\r\n\r\n回顾一下，EIP 背后的理由是，与其多次部署一个 _庞大_ 的合约，不如只部署一个超级便宜的 **最小** 代理合约，该合约指向链上已经存在的庞大合约。\r\n\r\n最小意味着 _最小_。也就是说，代理合约所做的只是 **委托** 所有调用到实现合约——没有更多，没有更少。确保你 **不要将 EIP 1167 最小代理合约与** [**代理模式**](https://blog.openzeppelin.com/the-state-of-smart-contract-upgrades/) **混淆，用于合约升级**。\r\n\r\n**EIP 1167 与可升级性无关**，也不试图取代它。\r\n\r\n## 从基本原理构建代理\r\n\r\n首先，让我们想一想最小代理需要做什么：\r\n\r\n1. 接收一些数据\r\n2. 使用 [DELEGATECALL](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7.md) 指令将接收到的数据转发到实现合约。\r\n3. 获取外部调用的结果（即 DELEGATECALL 的结果）\r\n4. 如果步骤 3 成功，则将外部调用的结果返回给调用者，否则撤销交易。\r\n\r\n就这样！所以让我们尝试 _非常粗略_ 地将这四个步骤映射到 EVM 指令中：\r\n\r\n| 我们想要的 | EVM 指令 | 简短说明 |\r\n| --- | --- | --- |\r\n| 解析来自调用者的数据 | [CALLDATACOPY](https://ethervm.io/#CALLDATACOPY) | calldata 是在交易中发送到合约的数据，因此我们需要将其复制到内存中，以便稍后能够转发。 |\r\n| 执行对实现合约的 DELEGATECALL | [DELEGATECALL](https://ethervm.io/#DELEGATECALL) | 这没有什么意外。在这里，我们将转发获得的 calldata 到实现合约。 |\r\n| 检索外部调用的结果 | [RETURNDATACOPY](https://ethervm.io/#RETURNDATACOPY) | 我们将 DELEGATECALL 返回的数据复制到内存中。 |\r\n| 将数据返回给调用者或撤销交易 | [JUMPI](https://ethervm.io/#JUMPI), [RETURN](https://ethervm.io/#RETURN), [REVERT](https://ethervm.io/#REVERT) | 根据外部调用的成功/失败状态，我们要么返回数据，要么撤销交易。 |\r\n\r\n好吧，这并不是那么难，对吧？我们已经走了一半的路！我们只需要解决一些小的实现细节。\r\n\r\n## 通往最小代理的路径\r\n\r\n记住这 4 个步骤。获取 calldata，委托调用，获取返回的数据，然后返回或撤销。简单易行。\r\n\r\n让我们假设我们从一个空栈和干净的内存开始。\r\n\r\n### 1\\. 获取 calldata\r\n\r\n*   **主要指令**： [CALLDATACOPY](https://ethervm.io/#CALLDATACOPY)\r\n*   **正式描述**：将当前环境中的输入数据复制到内存。\r\n*   它有 3 个参数：\r\n\r\n| #   | 参数 | 我们将传递的内容 |\r\n| --- | --- | --- |\r\n| 1   | 将 calldata 复制到的内存槽 | 0   |\r\n| 2   | 数据开始的位置 | 0   |\r\n| 3   | 我们想要复制的数据量 | CALLDATASIZE，因为我们想复制所有 calldata |\r\n\r\n请注意，要获取 calldata 的大小，我们可以使用方便的指令 [CALLDATASIZE](https://ethervm.io/#CALLDATASIZE)。\r\n\r\n记住，我们在 EVM 级别工作，因此在调用 CALLDATACOPY 之前，我们需要手动准备栈以传递参数。我们必须得到一个看起来像这样的栈：\r\n\r\n\\[ 0 | 0 | calldata size (\"cds\" 从现在开始) \\]\r\n\r\n所以，我们可以简单地做：\r\n\r\n| 代码 | 指令 | 栈 | 内存 |\r\n| --- | --- | --- | --- |\r\n| 36  | CALLDATASIZE | cds | –   |\r\n| 3d  | RETURNDATASIZE | 0 cds | –   |\r\n| 3d  | RETURNDATASIZE | 0 0 cds | –   |\r\n| 37  | CALLDATACOPY | –   | \\[0, cds\\] = calldata |\r\n\r\n我们为什么使用 RETURNDATASIZE？好吧，我们需要将两个零推入栈中。理想情况下，你只需使用 PUSH 指令来做到这一点，但这里有个问题：**A** **PUSH 的费用是 3 个 gas 单位，而 RETURNDATASIZE 仅需 2 个**。\r\n\r\n![](https://img.learnblockchain.cn/attachments/migrate/1733801664600)\r\n\r\n太棒了！**步骤 1 完成。Calldata 已在内存中**。\r\n\r\n### 2\\. 委托调用\r\n\r\n*   **主要指令**： [DELEGATECALL](https://ethervm.io/#DELEGATECALL)\r\n*   **正式描述**：在保持当前发送者和价值的值的同时，向一个账户的代码发送消息调用。\r\n*   它需要六个参数：\r\n\r\n| #   | 参数 | 我们将传递的内容 |\r\n| --- | --- | --- |\r\n| 1   | 我们想要转发的 gas 量 | 全部，使用 GAS 指令 |\r\n| 2   | 代理委托调用的合约地址 | 在最小代理的字节码中硬编码的地址（我们称之为 addr） |\r\n| 3   | 转发数据开始的内存槽 | 0   |\r\n| 4   | 转发数据的大小 | cds |\r\n| 5   | 返回数据将写入的内存槽 | 0（我们不会写入内存，而是返回它） |\r\n| 6   | 要写入内存的返回数据大小 | 0（我们不会写入内存，而是返回它） |\r\n\r\n因此，我们必须得到一个看起来像这样的栈：\r\n\r\n\\[ gas | addr | 0 | cds | 0 | 0 \\]\r\n\r\n而且出于一个 _非常_ 特定的原因（稍后解释），我们将向栈中推送一个额外的 0。它不会被 DELEGATECALL 使用，因为这只是出于整体效率的考虑。因此，栈实际上应该看起来像这样：\r\n\r\n\\[ gas | addr | 0 | cds | 0 | 0 | 0 \\]\r\n\r\n前六个项目是 DELEGATECALL 的参数。\r\n\r\n构建我们所需栈的最佳指令集如下：\r\n\r\n| 代码 | 指令 | 栈 | 内存 |\r\n| --- | --- | --- | --- |\r\n| 3d  | RETURNDATASIZE | 0   | \\[0, cds\\] = calldata |\r\n| 3d  | RETURNDATASIZE | 0 0 | \\[0, cds\\] = calldata |\r\n| 3d  | RETURNDATASIZE | 0 0 0 | \\[0, cds\\] = calldata |\r\n| 36  | CALLDATASIZE | cds 0 0 0 | \\[0, cds\\] = calldata |\r\n| 3d  | RETURNDATASIZE | 0 cds 0 0 0 | \\[0, cds\\] = calldata |\r\n| 73 addr | PUSH20 0x123… | addr 0 cds 0 0 0 | \\[0, cds\\] = calldata |\r\n| 5a  | GAS | gas addr 0 cds 0 0 0 | \\[0, cds\\] = calldata |\r\n| f4  | DELEGATECALL | success 0 | \\[0, cds\\] = calldata |\r\n\r\n再次强调，我们不使用 PUSH 将零添加到栈中——我们使用 RETURNDATASIZE，因为这样更便宜。不要过多关注上表中的内存列。它只是包含了步骤 1 的剩余数据。\r\n\r\n还要注意，DELEGATECALL 消耗了前六个项目，并将结果（命名为 success）推入栈中。栈中剩下的 0 是我们之前推入的零，原因我们即将理解。\r\n\r\n现在，我们已经执行了一个 DELEGATECALL 到实现合约，转发了我们在步骤 1 中获得的 calldata。太好了！让我们继续进行步骤 3。\r\n\r\n### 3\\. 获取外部调用的结果\r\n\r\n根据 DELEGATECALL 推入栈中的项目，我们可以判断调用是否成功。但是如果外部调用返回了一些数据，比如错误消息或函数的返回值呢？\r\n\r\nEVM 为我们提供了一条特定的指令，帮助我们检索这些数据。\r\n\r\n*   **主要指令**：[RETURNDATACOPY](https://ethervm.io/#RETURNDATACOPY)\r\n*   **正式描述**：将上一个调用的输出数据复制到内存中。\r\n*   我们需要指定 3 个参数：\r\n\r\n| #   | 参数 | 我们将传递的内容 |\r\n| --- | --- | --- |\r\n| 1   | 我们希望将返回的数据复制到内存中的位置 | 0   |\r\n| 2   | 返回数据的起始位置 | 0   |\r\n| 3   | 我们希望复制的返回数据的长度 | 外部调用后的返回数据大小（从现在开始称为“rds”） |\r\n\r\n对于最后一个参数，我们将使用 RETURNDATASIZE 的结果。请记住，现在由于我们已经执行了外部调用，它可能不会像以前那样返回 0。\r\n\r\n要继续，我们需要一个栈，其中前 3 个项目看起来像：\r\n\r\n\\[ 0 | 0 | rds \\]\r\n\r\n请记住，**栈中仍然有两个项目**，它们是在步骤 2 后留下的，因此当前栈看起来像：\r\n\r\n\\[ success | 0 \\]\r\n\r\n因此，写入 `[ 0 | 0 | rds ]` 到栈顶并执行 RETURNDATACOPY 指令的最佳指令集是：\r\n\r\n| 代码 | 指令 | 栈 | 内存 |\r\n| --- | --- | --- | --- |\r\n| 3d  | RETURNDATASIZE | rds success 0 | \\[0, cds\\] = calldata |\r\n| 82  | DUP3 | 0 rds success 0 | \\[0, cds\\] = calldata |\r\n| 80  | DUP1 | 0 0 rds success 0 | \\[0, cds\\] = calldata |\r\n| 3e  | RETURNDATACOPY | success 0 | \\[0, rds\\] = 返回数据 _(当 rds < cds 时，内存 \\[rds, cds\\] 中可能会有一些无关的剩余数据)_ |\r\n\r\n执行 DUP1 后的前 3 个项目 `[ 0 | 0 | rds | ... ]` 是 RETURNDATACOPY 的参数，它将所有返回的数据写入内存，从槽 0 开始（部分或完全覆盖这些槽中的内容）。\r\n\r\n我们已经成功地将所有从 DELEGATECALL 返回的数据复制到内存中。请注意，我们在栈中留下了两个项目，我们将在最后阶段使用它们。\r\n\r\n### 4\\. 最终阶段：返回或回滚\r\n\r\n我们收到了数据，然后执行了 DELEGATECALL，最后将返回的数据复制到内存中。是时候做出重大最终决定：我们应该返回还是回滚？\r\n\r\n这完全取决于我们在栈中拥有的 success 项是否为 0。\r\n\r\n*   **如果** **success** **为 0**，则 DELEGATECALL [失败](https://github.com/ethereum/go-ethereum/blob/b2f696e0258ca57adc8f3568eaadd487bd622eb1/core/vm/instructions.go#L820)，我们必须回滚。\r\n*   **如果** **success** **不为 0**，则 DELEGATECALL [成功](https://github.com/ethereum/go-ethereum/blob/b2f696e0258ca57adc8f3568eaadd487bd622eb1/core/vm/instructions.go#L822)，我们必须返回。\r\n\r\n在 EVM 语言中，if 条件可以使用 JUMPI 表示。但是在跳转之前，我们必须做好准备。\r\n\r\n现在内存中的 \\[0 – rds\\] 需要通过 REVERT 或 RETURN 指令发送回调用者。这两个指令都需要两个内存指针作为参数。这意味着在栈中的某个位置我们需要有：\r\n\r\n\\[ 0 | rds \\]\r\n\r\n要到达 REVERT 或 RETURN，我们需要使用 [JUMPI](https://ethervm.io/#JUMPI) 指令，这首先需要知道跳转的目标和要评估的条件（在我们的情况下，就是栈中已经存在的 success 项）。因为 JUMPI 必须在 REVERT 或 RETURN 之前，所以我们的栈应该看起来像：\r\n\r\n\\[ dest | success | 0 | rds \\]\r\n\r\n前两个项目是 JUMPI 的参数，后两个是 REVERT 或 RETURN 的参数。现在，dest 只是一个字节码指令位置的占位符，只有在事后才能定义。\r\n\r\n如果我们当前的栈是：\r\n\r\n\\[ success | 0 \\]\r\n\r\n一个最小的指令集，可以将我们带到所需的栈并根据 success 项跳转到 dest，是：\r\n\r\n| 代码 | 指令 | 栈 | 内存 |\r\n| --- | --- | --- | --- |\r\n| 90  | SWAP1 | 0 success | \\[0, rds\\] = 返回数据 |\r\n| 3d  | RETURNDATASIZE | rds 0 success | \\[0, rds\\] = 返回数据 |\r\n| 91  | SWAP2 | success 0 rds | \\[0, rds\\] = 返回数据 |\r\n| 60 dest | PUSH1 dest | dest success 0 rds | \\[0, rds\\] = 返回数据 |\r\n| 57  | JUMPI | 0 rds | \\[0, rds\\] = 返回数据 |\r\n\r\n跳转后，执行必须在 success 为 0 时到达 REVERT（不跳转）或 RETURN。\r\n\r\n| 代码 | 指令 | 栈 | 内存 |\r\n| --- | --- | --- | --- |\r\n| fd  | REVERT | –   | \\[0, rds\\] = 返回数据 |\r\n| 5b  | JUMPDEST | 0 rds | \\[0, rds\\] = 返回数据 |\r\n| f3  | RETURN | –   | \\[0, rds\\] = 返回数据 |\r\n\r\n我希望你已经记住了我们的代码到目前为止有多少字节……你记得吗？我之前告诉过你，dest 只能在事后定义——现在是时候这样做了。\r\n\r\n这组 EVM 运行时代码的完整指令集由 45 字节组成，JUMPDEST 占据位置 43。在十六进制中，它位于位置 2b。这就是为什么在 EIP 的规范中，你会看到我们使用 dest 的地方是 2b。\r\n\r\n我们从头构建的最小代理的最终 **运行时代码** 是：\r\n\r\n`363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3`，其中索引 10-29（包含）处的字节必须替换为实现合约的 20 字节地址。\r\n\r\n我们完成了，对吧？\r\n\r\n好吧……不 😉\r\n\r\n## 创建代码\r\n\r\n到目前为止，我们编写了 EIP 1167 的“运行时”代码。这是一个 _已部署_ 的最小代理的代码。然而，只需一次失败的交易，你就会意识到这段代码不能用于部署最小代理。为此，我们将需要创建代码。\r\n\r\n有关运行时代码与创建代码之间差异的解释，请参考 EVM 代码圣经，唯一的 [Ale Santander: “Deconstructing a Solidity Contract – Part 2: creation vs. runtime”](https://blog.openzeppelin.com/deconstructing-a-solidity-contract-part-ii-creation-vs-runtime-6b9d60ecb44c/)。\r\n\r\n我们现在需要的是一组 EVM 指令，这些指令将返回并将此运行时代码放入区块链中。幸运的是，这相当简单：\r\n\r\n1. 复制运行时代码到内存中。\r\n2. 将代码放入内存并返回。\r\n\r\n### 将运行时代码复制到内存\r\n\r\n我们已经在上一节中构建了我们的运行时代码。\r\n\r\n现在，我们的工作是将一组指令组合在一起，将那长串字节放入内存中。不出所料，EVM 为此提供了一条指令：\r\n\r\n*   **主要指令**：[CODECOPY](https://ethervm.io/#CODECOPY)\r\n*   **正式描述**：将当前环境中运行的代码复制到内存中。\r\n*   它需要 3 个参数：\r\n\r\n| #   | 参数 | 我们将传递的内容 |\r\n| --- | --- | --- |\r\n| 1   | 我们希望将代码复制到内存中的位置 | 0   |\r\n| 2   | 要复制的代码的起始位置 | 10 (0a 在十六进制中) |\r\n| 3   | 要复制的字节序列的长度 | 45 (2d 在十六进制中) |\r\n\r\n为什么是 45？这是运行时代码的字节数。为什么是 10？你会看到。总之，为了执行 CODECOPY，我们需要得到一个看起来像这样的栈：\r\n\r\n\\[ 0 | 0a | 2d \\]\r\n\r\n一组指令（遵循 EIP）可以将我们带到那里：\r\n\r\n| 代码 | 指令 | 栈 | 内存 |\r\n| --- | --- | --- | --- |\r\n| 3d  | RETURNDATASIZE | 0   | –   |\r\n| 602d | PUSH1 2d | 2d 0 | –   |\r\n| 80  | DUP1 | 2d 2d 0 | –   |\r\n| 600a | PUSH1 0a | 0a 2d 2d 0 | –   |\r\n| 3d  | RETURNDATASIZE | 0 0a 2d 2d 0 | –   |\r\n| 39  | CODECOPY | 2d 0 | \\[0-2d\\]: 运行时代码 |\r\n\r\n那么在 CODECOPY 之后栈上留下的 `2d` 和 `0` 是什么呢？那是即将到来的 RETURN 指令的参数，它需要 `0` 和 `2d` 作为参数。\r\n\r\n|     |     |     |     |\r\n| --- | --- | --- | --- |\r\n| 81  | DUP2 | 0 2d 0 | \\[0-2d\\]: 运行时代码 |\r\n| f3  | RETURN | 0   | \\[0-2d\\]: 运行时代码 |\r\n\r\n注意栈上还留下一个 `0`。这意味着最小代理的创建代码实际上可以变得更加高效。你能猜到怎么做吗？你会改变哪些指令？第一个 RETURNDATASIZE 和最后一个 DUP2 看起来不是很好的候选者吗？\r\n\r\n这一组指令的长度是 10 字节。这就是为什么我们将 10（以十六进制表示为 `0a`）作为第二个参数传递给 CODECOPY 指令。最后，表示创建代码的字节序列，包括运行时代码，是：\r\n\r\n3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3\r\n\r\n索引 20 到 39（包括）处的字节需替换为逻辑合约的 20 字节地址。\r\n\r\n## 如何部署最小代理\r\n\r\n如果你想从 Solidity 合约部署 EIP 1167 最小代理，可以使用 [**Clones**](https://docs.openzeppelin.com/contracts/api/proxy#Clones) 库在 OpenZeppelin Contracts 中。\r\n\r\n*感谢伟大的 [Andres Bachfischer](https://twitter.com/andresbach_) 与我一起冒险并帮助我审阅这篇文章*\r\n\r\n> 我是 [AI 翻译官](https://learnblockchain.cn/people/19584)，为大家转译优秀英文文章，如有翻译不通的地方，在[这里](https://github.com/lbc-team/Pioneer/blob/master/translations/10190.md)修改，还请包涵～"},"author":{"user":"https://learnblockchain.cn/people/19584","address":"0xbEb536001cB9c343447519ad32F6db2bB7d45D3D"},"history":null,"timestamp":1733803095,"version":1}