这篇文章深入探讨了Solidity中的delegatecall方法,阐明了其工作原理、操作的安全性以及如何有效管理合约状态变量布局。
一些开发者害怕 ‘delegatecall’,因为他们被告知它是“危险的”。恐惧和危险来自对某事如何工作的理解不足以及如何安全地使用它。例如,我们中的大多数人并不害怕开车,因为我们对汽车的工作原理有足够的了解,并且我们知道如何安全地驾驶。
当合约使用 delegatecall 发起函数调用时,它会从另一个合约加载函数代码并将其作为自己的代码执行。
当使用 delegatecall 执行函数时,这些值不会改变:
address(this)
msg.sender
msg.value
对状态变量的读取和写入发生在加载并执行函数的合约上。读取和写入从未发生在检索函数的合约上。
所以如果 ContractA 使用 delegatecall 执行 ContractB 的某个函数,那么以下两点都是正确的:
ContractA 中的状态变量可以被读取和写入。
ContractB 中的状态变量永远不会被读取或写入。
ContractA 和 ContractB 都可以声明相同的状态变量,而 ContractB 的函数可以读取和写入这些状态变量的值。但只有 ContractA 的状态变量会被读取或写入。
delegatecall 影响调用 delegatecall 函数的合约的状态变量。持有被借用的函数的合约的状态变量不会被读取或写入。
我们来看一个简单的例子。
ContractA 有以下内容:
address(this) == 0x2791bca1f2de4661ed88a30c99a7a9449aa84174
状态变量 ‘string tokenName’ 的值是 “FunToken”
一个名为 `initialize()` 的外部函数,它使用 delegatecall 调用 ContractB 中的 `setTokenName(string calldata _newName)` 函数。
ContractB 有以下内容:
address(this) == 0x6b175474e89094c44da98b954eedeac495271d0f
状态变量 ‘string tokenName’ 的值是 “BoringToken”
一个名为 `setTokenName(string calldata _newName)` 的外部函数,它将状态变量 `tokenName` 设置为 ‘_newName’ 的值
当 ContractA 中的 `initialize()` 函数以 2 ETH 被调用时,发生的事情如下:
这些值被设置:
address(this) == 0x2791bca1f2de4661ed88a30c99a7a9449aa84174
msg.sender == 0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B
msg.value == 2 ETH
`initialize()` 函数使用 delegatecall 调用 ContractB 中的 `setTokenName` 函数。当执行时,`setTokenName` 中的值如下。注意,它们没有变化。
address(this) == 0x2791bca1f2de4661ed88a30c99a7a9449aa84174
msg.sender == 0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B
msg.value == 2 ETH
以下是代码的样子:
在 Solidity 中,地址有一个 `delegatecall` 方法,可以让你执行 delegatecall。这个 delegatecall 方法返回一个布尔状态变量,告诉你函数调用是否回退。delegatecall 函数返回第二个值,即函数调用的任何返回值。请参阅上面的代码示例。
现在你理解了 delegatecall 的工作原理,让我们看看如何安全地使用它。
请不要通过 delegatecall 执行不可信的代码,因为它可能恶意地修改状态变量或调用 `selfdestruct` 来销毁调用合约。使用权限或身份验证或其他形式的控制来指定或更改通过 delegatecall 执行的函数和合约。
如果在一个不是合约且没有代码的地址上调用 delegatecall,delegatecall 将返回 ‘True’ 的状态值。如果代码预期 delegatecall 函数在不能执行时返回 `False`,这可能会导致错误。
如果你不确定一个地址变量是否始终包含一个有代码的地址,并且在其上使用 delegatecall,那么在使用 delegatecall 之前先检查该变量的任意地址是否有代码,如果没有代码就回退。以下是检查一个地址是否有代码的示例代码:
Solidity 使用数字地址空间在合约中存储数据。第一个状态变量存储在位置 0,下一个状态变量存储在位置 1,下一个状态变量存储在位置 2,等等。
执行 delegatecall 的合约和函数共享与调用合约相同的状态变量地址空间,因为使用 delegatecall 调用的函数读取和写入调用合约的状态变量。
因此,调用 delegatecall 的合约和被执行的合约必须对读取和写入的状态变量位置具有相同的状态变量布局。相同的状态变量布局意味着两个合约中声明的相同状态变量的顺序相同。
如果调用 delegatecall 的合约和借用函数的合约没有相同的状态变量布局,并且它们在合约存储中读取或写入相同的位置,则它们将覆盖或错误解释彼此的状态变量。
例如,假设 ContractA 声明了状态变量 ‘uint first;’ 和 ‘bytes32 second;’,而 ContractB 声明了状态变量 ‘uint first;’ 和 ‘string name;’。它们在合约存储的第 1 位置有不同的状态变量(‘bytes32 second’ 和 ‘string name’),因此在它们之间使用 delegatecall 时,它们将相互写入和读取错误的数据。
在实践中,当应用策略时,管理调用 delegatecall 的合约和通过 delegatecall 执行的合约的状态变量布局并不难。以下是一些在生产中成功使用的已知策略:
一种策略是创建一个合约,声明所有在使用 delegatecall 的所有合约中使用的状态变量。它可以被称为 ‘Storage’ 或其他名称。然后它可以被每个共享相同存储地址空间的合约继承。这一策略是可行的,但也有其局限性,而在我看来,我发现了一个类似但更好的策略。
继承存储的局限性在于,它阻止合约的重复使用。如果你部署了一个使用继承存储的合约,那么在使用 delegatecall 时,很可能无法与具有不同状态变量的不同合约重复使用该已部署的合约。
在我看来,另一个局限性是太容易意外地将某些内部函数或局部变量命名为与状态变量相同的名称并导致名称冲突。但可以通过使用代码命名约定来克服此问题,以防止此类名称冲突。
使用 delegatecall 之间的合约实际上不需要按照相同的顺序声明相同的状态变量如果它们存储数据的位置不同。
如前所述,Solidity 自动将状态变量存储在从 0 开始并递增的位置。但是我们不必使用 Solidity 的默认存储布局机制。我们不必从 0 开始存储数据。我们可以指定从地址空间的某个位置开始存储数据。对于不同的合约,我们可以指定不同的位置开始存储数据,从而防止不同状态变量的不同合约之间发生存储位置冲突。这就是立方体存储的作用。
我们可以对一个唯一的字符串进行哈希,以获得一个随机存储位置并在那里存储一个结构。这个结构可以包含我们想要的所有状态变量。唯一的字符串可以像特定功能的命名空间。
例如,我们可以实现一个 ERC721 合约。这个合约可以将一个名为 ‘ERC721Storage’ 的结构存储在位置 ‘keccak256("com.myproject.erc721");’。这个结构可以包含与 ERC721 功能相关的所有状态变量,供 ERC721 合约读取和写入。这有几个不错的优点。其中之一是 ERC721 合约是可重用的。ERC721 合约可以仅部署一次,已部署的 ERC721 合约可以与使用 delegatecall 的多个不同合约一起使用,并且这些合约使用不同的状态变量。另一个优点是,ERC721 合约不被与其无关的状态变量声明所淹没。
立方体存储的另一个优点是,Solidity 库的内部函数可以像任何常规合约函数那样访问立方体存储。我写了一篇关于使用 Solidity 库与立方体存储的博客,详见:Solidity Libraries Can't Have State Variables -- Oh Yes They Can!
有关立方体存储和代码示例的更多信息,请参见这篇博客:How Diamond Storage Works。我还建议阅读 Understanding Diamonds on Ethereum。
应用存储类似于继承存储,但它解决了名称冲突的问题,名称冲突在这种情况下很容易发生,例如将某些内部函数或局部变量意外命名为与状态变量相同的名称。这可能看似微不足道,但我发现实际上这非常好,因为应用存储还以更容易扫描和阅读的方式对代码进行了区分。如果你关心代码的可读性,那么你会喜欢应用存储。
应用存储强制执行一种命名或访问约定,这样就不可能与其他内容发生状态变量名称冲突。
一个名为 AppStorage 的结构体在 Solidity 文件中编写。AppStorage 结构体包含将在合约之间共享的状态变量。要使用它,一个合约导入 AppStorage 结构体,并将 `AppStorage internal s;` 声明为合约中的第一个也是唯一的状态变量。然后合约通过结构体访问所有状态变量,如下所示: `s.myFirstVariable`, `s.mySecondVariable` 等等。以下是一个示例:
重要的是,要将 ‘AppStorage internal s;’ 声明为使用它的所有合约中的第一个也是唯一的状态变量。这将其放在存储地址空间的第 0 位置。因此,如果所有合约都将其作为第一个也是唯一的状态变量声明,则在使用 delegatecall 的合约之间的存储数据将会正确对齐。请勿将状态变量直接添加到合约中,因为这将与 AppStorage 结构体中声明的状态变量发生冲突。要添加更多状态变量,请将其添加到 AppStorage 结构体的末尾或使用立方体存储。
应用存储比立方体存储更易于使用,因为在每个函数中,立方体存储需要获取一个结构的指针,而使用应用存储时,`s` 结构指针会自动在整个合约中可用。
应用存储相对于继承存储的另一个优点是,应用存储可以被 Solidity 库以与立方体存储相同的方式访问。AppStorage 结构体始终存储在位置 0,因此 Solidity 库中的内部函数可以使用此位置将 ‘s’ 存储指针初始化为指向 AppStorage 结构体。以下是一个示例:
对 AppStorage 结构的存储指针也可以作为参数传递给库函数,如上面的 `myLibraryFunction2` 函数中所示。
应用存储可以与合约继承一起使用。这是通过在合约中声明 ‘AppStorage internal s;’ 来实现的。然后,所有使用应用存储的合约均继承该合约。
应用存储对不打算与使用应用存储或继承存储的其他项目或合约重复使用的特定于应用或项目的合约特别有用。应用存储可以与同一合约中的立方体存储一起使用。
应用存储在不使用 delegatecall 的智能合约中也很有用,因为它使代码更具可读性并防止名称冲突。
查看这篇关于应用存储的博客,了解更多信息:AppStorage Pattern for State Variables in Solidity
以下是使用 delegatecall 的智能合约架构:
代理合约使用 delegatecall 将外部函数调用委托给实现合约。代理合约用于实现可升级合约。要升级代理合约,只需将其指向不同的实现合约。
由同一代理合约使用的不同版本的实现合约必须使用一种策略来管理状态变量布局。否则,不同的实现可以在错误的存储位置读取和写入数据。实现合约可以使用继承存储或立方体存储或应用存储。
SolidState 智能合约库 支持使用立方体存储的代理合约和实现合约。
EIP-2535 钻石 是一个标准,支持构建可以在生产中扩展的模块化智能合约系统。
一个钻石是一个代理合约,拥有多个实现合约。通过该标准和这个介绍了解更多有关钻石的信息:Introduction to the Diamond Standard, EIP-2535 Diamonds
钻石的实现合约,称为面,将能够使用继承存储、立方体存储和应用存储。
有多个经过审计的参考实现,可以开始使用钻石。请参见此处:diamond reference implementations。
solidstate-solidity 智能合约库支持钻石。
hardhat-deploy 插件支持部署和升级钻石。
Solidity 库并不是像代理和钻石那样的智能合约架构。它们是 Solidity 语言的一种工具和组成部分。
来自 Solidity 文档:
库类似于合约,但它们的目的是在特定地址上只部署一次,并且它们的代码通过 EVM 的
DELEGATECALL
特性被重新使用。
Solidity 库 的外部函数是通过 delegatecall 执行的。
Solidity 库可以通过将存储指针用作函数的参数来访问状态变量。Solidity 库还可以访问并使用立方体存储和应用存储。以下是说明 Solidity 库如何使用立方体存储的文章:Solidity Libraries Can't Have State Variables -- Oh Yes They Can!
- 原文链接: eip2535diamonds.substack...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!