{"content":{"title":"Solidity delegatecall 的使用和误区","body":"> * 原文链接： https://medium.com/@jeremythen16/solidity-delegatecall-useage-and-pitfalls-5c37eaa5bd5d\r\n> * 译文出自：[登链翻译计划](https://github.com/lbc-team/Pioneer)\r\n> * 译者：[翻译小组](https://learnblockchain.cn/people/412)  校对：[Tiny 熊](https://learnblockchain.cn/people/15)\r\n> * 本文永久链接：[learnblockchain.cn/article…](https://learnblockchain.cn/article/5372)\r\n\r\n\r\nSolidity `delegatecall` （委托调用）是一个低级别的函数，它允许我们在主合约的上下文的情况下加载和调用另一个合约的代码。这意味着被调用合约的代码被执行，但被调用合约所做的任何状态改变实际上是在主合约的存储中进行的，而不是在被调用合约的存储中。\r\n\r\n这对创建库和代理合约模式很有用，我们把调用委托给不同的合约，\"给它们\"权限来修改调用合约的状态。\r\n\r\n这个功能也有一些我们需要注意的隐患，基本上是本文要重点关注的内容。\r\n\r\n正如[另一篇关于存储中状态变量布局的文章](https://coinsbench.com/solidity-layout-and-access-of-storage-variables-simply-explained-1ce964d7c738) 和 [Solidity 文档](https://learnblockchain.cn/docs/solidity/internals/layout_in_storage.html) 中解释的那样，合约中声明的每个状态变量都在存储中占据一个槽，如果它们的类型小于32字节，并且可以一起放入一个槽中，则可能与其他状态变量共享一个公共槽。\r\n\r\n所以，当我们访问这些状态变量，给它们赋值或从它们那里读取时，Solidity 用该状态变量的声明位置来知道访问哪个存储槽并从它那里读取或更新它。\r\n\r\n例如，给定以下合约:\r\n\r\n```solidity\r\ncontract EntryPointContract {\r\n  address public owner = msg.sender;\r\n  uint256 public id = 5;\r\n  uint256 public updatedAt = block.timestamp;\r\n}\r\n```\r\n\r\n我们看到它声明了3个状态变量，`owner`，`id`和`updatedAt`。这些状态变量有赋值，在存储中，它们看起来像这样:\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_E5XAMg7HBNcSvjQEiPBPaQ.png)\r\n\r\n我们看到，在索引0 存储槽处，我们有第一个状态变量的值使用零填充，因为每个槽可以容纳32个字节的数据。\r\n\r\n第二个槽，索引为1，保存了 \"id\"状态变量的值。\r\n\r\n第三个槽，索引为2，有第三个状态变量`updatedAt`的值。所有存储的数据都以十六进制表示，所以[转换](https://www.rapidtables.com/convert/number/hex-to-decimal.html) `0x62fc3adb`到十进制是1660697307，用js转换为日期：\r\n\r\n```JavaScript\r\nconst date = new Date(1660697307 * 1000);\r\nconsole.log(date)\r\n```\r\n\r\n结果：\r\n\r\n```\r\nTue Aug 16 2022 20:48:27 GMT-0400 (Atlantic Standard Time))\r\n```\r\n\r\n所以，在访问状态变量`id`时，我们是在访问索引为1的槽。\r\n\r\n很好，那么，使用`delegatecall`的陷阱在哪里？\r\n\r\n为了让委托合约对主合约的存储进行修改，它同样需要声明自己的变量，其顺序与主合约的声明顺序完全相同，而且通常有相同数量的状态变量。\r\n\r\n例如，上面的 `EntryPointContract` 的委托合约，需要看起来是这样的：\r\n\r\n```solidity\r\ncontract DelegateContract {\r\n  address public owner;\r\n  uint256 public id;\r\n  uint256 public updatedAt;\r\n}\r\n```\r\n\r\n有完全相同的状态变量，完全相同的类型，完全相同的顺序，最好有完全相同数量的状态变量。在此案例中，每个合约有3个状态变量。\r\n\r\n让我们展示一下这两个合约：\r\n\r\n```solidity\r\ncontract DelegateContract {\r\n  address public owner;\r\n  uint256 public id;\r\n  uint256 public updatedAt;\r\n\r\n  function setValues(uint256 _newId) public {\r\n    id = _newId;\r\n  }\r\n\r\n}\r\n\r\ncontract EntryPointContract {\r\n  address public owner = msg.sender;\r\n  uint256 public id = 5;\r\n  uint256 public updatedAt = block.timestamp;\r\n  address delegateContract;\r\n\r\n  constructor(address _delegateContract) {\r\n    delegateContract = _delegateContract;\r\n  }\r\n\r\n  function delegate(uint256 _newId) public returns(bool) {\r\n    (bool success, ) =\r\n    delegateContract.delegatecall(abi.encodeWithSignature(\"setValues(uint256)\",\r\n      _newId));\r\n    return success;\r\n  }\r\n\r\n}\r\n```\r\n\r\n这里我们看到了一个真正简单的代理合约的实现。`EntryPointContract`有一个构造函数，接收部署的`DelegateContract`的地址来委托它的调用，以便自己的状态被`DelegateContract`修改。\r\n\r\n该`delegate`函数收到一个要设置的`_newId`，所以它使用低级别的`delegatecall`将该调用委托给`DelegateContract` 来更新`id`变量。\r\n\r\n在用新的id值调用`delegate`函数，并检查`EntryPointContract`和`DelegateContract`合约的变量id值后，我们看到只有`EntryPointContract`的状态变量`id`有值，而`DelegateContract`的`id`状态变量没有赋值，仍然被设置为0，因为`DelegateContract`修改的不是它自己的存储，而是`EntryPointContract`的存储。\r\n\r\n*很好!*\r\n\r\n在第7行，我们看到`id = _newId`，但是，虽然听起来很奇怪，它并没有修改`EntryPointContract`的`id`变量，却实际上修改了`EntryPointContract`的存储槽， 我们知道`EntryPointContract`中的`id`变量被声明在索引为1的槽中，如上图所示。\r\n\r\n这可能会引起混淆，因为我们实际上看到代码正在给`DelegateContract`中的`id`变量赋值，你可能认为不管这个变量在`EntryPointContract`或`DelegateContract`中的位置在哪里，它仍然会修改`EntryPointContract`中的`id`状态变量槽。但是不是这样的。\r\n\r\n\r\n\r\n例如，在下面的合约中，我在`DelegateContract`中声明了`id`状态变量的第三个位置，这意味着现在它指向索引为2的槽，而不管`EntryPointContract`中的`id` 状态变量名。\r\n\r\n```solidity\r\ncontract DelegateContract {\r\n  address public owner;\r\n  // 注意：两个变量换了位置\r\n  uint256 public updatedAt;\r\n  uint256 public id;\r\n\r\n  function setValues(uint256 _newId) public {\r\n    id = _newId;\r\n  }\r\n\r\n}\r\n\r\ncontract EntryPointContract {\r\n  address public owner = msg.sender;\r\n  uint256 public id = 5;\r\n  uint256 public updatedAt = block.timestamp;\r\n  address delegateContract;\r\n\r\n  constructor(address _delegateContract) {\r\n    delegateContract = _delegateContract;\r\n  }\r\n\r\n  function delegate(uint256 _newId) public returns(bool) {\r\n    (bool success, ) =\r\n    delegateContract.delegatecall(abi.encodeWithSignature(\"setValues(uint256)\",\r\n      _newId));\r\n    return success;\r\n  }\r\n\r\n}\r\n```\r\n\r\n现在 ，如果我用一个新的id值15再次调用`delegate`，会发生什么？\r\n\r\n让我们看看...\r\n\r\n`DelegateContract`被部署在：0x2eD309e2aBC21e6036584dD748d051c0a6E03709\r\n\r\n我们可以用[Remix](https://remix.ethereum.org/)来分析它：\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_j6SxuTy7jI1wQ29xrPUliA.png)\r\n\r\n`EntryPointContract`被部署在: 0x172443F1D272BB9f6d03C35Ecf42A96041FabB09\r\n\r\n我们可以用Remix检查它的值：\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_eAyXtlcn6KV16sD5FOf4cA.png)\r\n\r\n很好!\r\n\r\n现在让我们 用参数 15调用`delegate`，看看会发生什么。\r\n\r\n检查一下`DelegateContract`的状态变量值：\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_3IsQo-BX8wCD-xQKRq-WBA.png)\r\n\r\n没有变化，正如预期的那样，因为它不应该改变自己的状态，因为它被委托了`EntryPointContract`的状态。\r\n\r\n让我们检查一下`EntryPointContract`的状态变量值（记住，我们希望`id`现在是15，其他都保持不变）。\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_VMzjmKGVrzINjguSgEdzwA.png)\r\n\r\n哦哦! `EntryPointContract`的`id`仍然是5，实际受到影响的状态变量是`updatedAt`。为什么？\r\n\r\n正如我在上面解释的，`DelegateContract`实际上不是通过名字来修改状态变量，而是通过它们在存储中的声明位置。\r\n\r\n我们知道，`id`状态变量在`EntryPointContract`中被声明在第二位，这意味着它将在存储中占据索引为1的槽。`updatedAt`在`EntryPointContract`中被声明为第三位，因此占据了索引为2的存储槽。但是我们看到，`DelegateContract`将`id`变量声明为第三位，而将`updatedAt`声明为第二位。所以，当`DelegateContract`试图修改`id`时，它实际上是在修改`EntryPointContract`存储槽的索引2，也就是`updatedAt`状态变量在`EntryPointContract`中的位置。这就是为什么我们看到`updatedAt`是被更新的，而不是`id`。\r\n\r\n让我们来详细说明一下：\r\n\r\n`EntryPointContract`存储显示了声明的状态变量的顺序和它们的值。\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_PNUnDL_Kx1q2sj3RrgphFg.png)\r\n\r\n`EntryPointContract`存储“发送到”（委托的）`DelegateContract`，按照`DelegateContract`中声明的顺序显示状态变量，但按照`EntryPointContract`状态变量的声明顺序显示数值：\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_N8V-umeVODNw_mI2mwVClA.png)\r\n\r\n所以，我们清楚地看到，在`DelegateContract`中，`id`变量实际上是指向`EntryPointContract`存储中的`updatedAt`值，而`DelegateContract`的`updatedAt`值实际上是指向`id`变量在`EntryPointContract`存储中有其值的槽。\r\n\r\n所以，这就是为什么我们在委托调用另一个合约时需要非常小心的原因，因为拥有相同的变量类型和名称并不能确保调用合约中的这些变量会被使用。它们需要在两个合约中以相同的顺序声明。\r\n\r\n另一个有趣的事实是，委托合约可以比主合约有更多的状态变量，有效地将值添加到主存储区，但它不能直接访问，因为主合约没有一个变量指向该存储区。\r\n\r\n让我们看看这些合约，以便更清楚理解：\r\n\r\n```solidity\r\n contract DelegateContract {\r\n   address public owner;\r\n   uint256 public id;\r\n   uint256 public updatedAt;\r\n   address public addressPlaceholder;\r\n   uint256 public unreachableValueByTheMainContract;\r\n\r\n   function setValues(uint256 _newId) public {\r\n     id = _newId;\r\n     unreachableValueByTheMainContract = 8;\r\n   }\r\n }\r\n\r\n contract EntryPointContract {\r\n   address public owner = msg.sender;\r\n   uint256 public id = 5;\r\n   uint256 public updatedAt = block.timestamp;\r\n   address public delegateContract;\r\n\r\n   constructor(address _delegateContract) {\r\n     delegateContract = _delegateContract;\r\n   }\r\n\r\n   function delegate(uint256 _newId) public returns(bool) {\r\n     (bool success, ) =\r\n     delegateContract.delegatecall(abi.encodeWithSignature(\"setValues(uint256)\",\r\n       _newId));\r\n     return success;\r\n   }\r\n\r\n }\r\n```\r\n\r\n我们看到，`EntryPointContract`仍然声明了4个状态变量，而`DelegateContract`声明了5个。我们知道，当`EntryPointContract`委托调用`DelegateContract`时，它将把自己的存储发送到`DelegateContract.`，但是`EntryPointContract`没有第五个状态变量（unreachableValueByTheMainContract）。那么，当`DelegateContract`修改它声明的但`EntryPointContract`没有声明的第五个变量时会发生什么？\r\n\r\n嗯，它实际上会修改`EntryPointContract`存储的槽索引4（第五个位置）。`EntryPointContract`将不能直接访问它，因为该槽没有对应声明的状态变量，但该值将在那里，我们可以用`web3.eth.getStorageAt(entryPointContractAddress, 4)`这样的方法来访问它。\r\n\r\n`EntryPointContract`被部署在0xA80a6609e0cA08ed3D531FA1B8bbCC945b8ff409，我们看到它的值：\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_VD2sJtiZn13ev1ecK9TiYA.png)\r\n\r\n现在让我们调用`delegate`，其值为18：\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_Brb3SsGtHNWUvwbVgVr0fg.png)\r\n\r\n棒极了! 但是设置为`unreachableValueByTheMainContract`的值8在哪里呢？让我们看看它是否在 `DelegateContract `状态下。\r\n\r\n![img](https://img.learnblockchain.cn/2023/02/10/1_ZS8flh4CZYt1dRezKvt60Q.png)  \r\n\r\n可以看到，它没有值。因为`DelegateContract`没有修改自己的状态，即使状态变量没有在`EntryPointContract`中声明。但由于`unreachableValueByMainContract`状态变量被声明在第五个位置（存储槽索引4），那么它无论如何都会影响`EntryPointContract`索引4的存储槽。我们可以直接检查它的值：\r\n\r\n```\r\nweb3.eth.getStorageAt(\"0xA80a6609e0cA08ed3D531FA1B8bbCC945b8ff409\", 4)\r\n```\r\n\r\n返回：\r\n\r\n```\r\n0x0000000000000000000000000000000000000000000000000000000000000008\r\n```\r\n\r\n是的! 说明`EntryPointContract` 确实保存了这个数据。\r\n\r\n这是一种有趣的方式，即智能合约可以在部署后被 \"扩展\"，只需在第一时间将其行动委托给另一个合约。这需要精心制作和设计。委托合约的地址需要能够在需要时被动态替换，这样入口点合约就可以在任何时候指向一个新的实现。\r\n\r\n有一些方法可以解决这个问题，其中之一就是[EIP-1967: Standard Proxy Storage Slots](https://eips.ethereum.org/EIPS/eip-1967)。\r\n\r\n## 结论\r\n\r\n`delegatecall`是一个强大但棘手的功能，如果使用得当，我们可以创建 `可扩展 `的智能合约，帮助我们修复漏洞，并为现有的智能合约增加新的功能，使其动态地将其行动委托给另一个合约并由其修改自己的状态。\r\n\r\n我们需要牢记代理合约和执行合约中的状态变量的顺序，以避免对存储数据进行非预期的修改。"},"author":{"user":"https://learnblockchain.cn/people/412","address":null},"history":"QmRFB5r7McSPybB5yQLEFU2JQFw9bKFVYL5M7hPEmouCWT","timestamp":1675999687,"version":1}