代理升级模式 - OpenZeppelin 文档

本文深入探讨了“非结构化存储”代理模式,这是 OpenZeppelin Upgrades 的基础构建块。它解决了智能合约升级的挑战,通过代理合约转发交易到逻辑合约,实现逻辑合约的替换,同时确保状态的存储和访问正确。文章还讨论了存储冲突问题以及如何使用initializer函数代替constructor,保证升级的顺利进行。

代理升级模式

本文介绍了“非结构化存储”代理模式,它是 OpenZeppelin Upgrades 的基本构建块。

想要更深入的了解,请参阅 我们的代理模式博客文章,其中讨论了对代理的需求,更详细地介绍了该主题的技术细节,详细阐述了为 OpenZeppelin Upgrades 考虑过的其他可能的代理模式等等。

为什么要升级合约?

从设计上讲,智能合约是不可变的。另一方面,软件质量在很大程度上取决于升级和修补源代码以生成迭代版本的能力。即使基于区块链的软件从该技术的不可变性中获益匪浅,但仍然需要一定程度的可变性来进行错误修复和潜在的产品改进。OpenZeppelin Upgrades 通过为智能合约提供易于使用、简单、稳健和可选择的升级机制来解决这种明显的矛盾,该机制可以由任何类型的治理来控制,无论是多重签名钱包、简单地址还是复杂的 DAO。

通过代理模式升级

基本思想是使用代理进行升级。第一个合约是一个简单的包装器或“代理”,用户直接与之交互,并负责转发与第二个合约之间的交易,第二个合约包含逻辑。要理解的关键概念是,逻辑合约可以被替换,而代理或访问点永远不会改变。这两个合约仍然是不可变的,因为它们的代码无法更改,但是逻辑合约可以简单地被另一个合约交换。因此,包装器可以指向不同的逻辑实现,从而使软件“升级”。

用户 ---- tx ---> 代理 ----------> Implementation_v0
                     |
                      ------------> Implementation_v1
                     |
                      ------------> Implementation_v2

代理转发

代理需要解决的最直接的问题是代理如何公开逻辑合约的整个接口,而不需要逻辑合约的整个接口的一对一映射。这将难以维护,容易出错,并且会使接口本身无法升级。因此,需要一种动态转发机制。下面代码介绍了这种机制的基础知识:

// 此代码仅用于“演示”目的。要在生产环境中实现此功能,建议使用 `@openzeppelin/contracts` 库中的 `Proxy` 合约。
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.2/contracts/proxy/Proxy.sol

assembly {
  // (1) 复制传入的调用数据
  calldatacopy(0, 0, calldatasize())

  // (2) 将调用转发到逻辑合约
  let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

  // (3) 检索返回数据
  returndatacopy(0, 0, returndatasize())

  // (4) 将返回数据转发回调用者
  switch result
  case 0 {
      revert(0, returndatasize())
  }
  default {
      return(0, returndatasize())
  }
}

此代码可以放在代理的 fallback function 中,并将把任何调用转发到具有任何参数集的任何函数到逻辑合约,而无需知道逻辑合约接口的任何特定信息。实质上,(1) calldata 被复制到内存,(2) 调用被转发到逻辑合约,(3) 从逻辑合约调用返回的数据被检索,并且 (4) 返回的数据被转发回调用者。

一个非常重要的事情是,该代码使用了 EVM 的 delegatecall 操作码,该操作码在调用者的状态上下文中执行被调用者的代码。也就是说,逻辑合约控制代理的状态,而逻辑合约的状态是无意义的。因此,代理不仅将交易转发到逻辑合约和从逻辑合约转发交易,还表示该对的状态。状态在代理中,而逻辑在代理指向的特定实现中。

非结构化存储代理

使用代理时,很快出现的一个问题与变量在代理合约中的存储方式有关。假设代理将逻辑合约的地址存储在其唯一的变量 address public _implementation; 中。现在,假设逻辑合约是一个基本 token,其第一个变量是 address public _owner。两个变量的大小都是 32 字节,并且就 EVM 所知,它们占据了代理调用的结果执行流程的第一个 slot。当逻辑合约写入 _owner 时,它在代理状态的范围内执行此操作,并且实际上写入 _implementation。这个问题可以称为“存储冲突”。

|Proxy                     |Implementation           |
|--------------------------|-------------------------|
|address _implementation   |address _owner           | <=== 存储冲突!
|...                       |mapping _balances        |
|                          |uint256 _supply          |
|                          |...                      |

有很多方法可以克服这个问题,OpenZeppelin Upgrades 实现的“非结构化存储”方法的工作方式如下。它不是将 _implementation 地址存储在代理的第一个存储 slot 中,而是选择一个伪随机 slot。这个 slot 足够随机,以至于逻辑合约在同一 slot 中声明变量的概率可以忽略不计。在代理可能拥有的任何其他变量中,例如管理员地址 (允许更新 _implementation 的值),也使用相同的随机化 slot 位置的原则。

|Proxy                     |Implementation           |
|--------------------------|-------------------------|
|...                       |address _owner           |
|...                       |mapping _balances        |
|...                       |uint256 _supply          |
|...                       |...                      |
|...                       |                         |
|...                       |                         |
|...                       |                         |
|...                       |                         |
|address _implementation   |                         | <=== 随机 slot。
|...                       |                         |
|...                       |                         |

以下是按照 EIP 1967 实现随机存储的一个示例:

bytes32 private constant implementationPosition = bytes32(uint256(
  keccak256('eip1967.proxy.implementation')) - 1
));

因此,逻辑合约无需关心覆盖代理的任何变量。面临此问题的其他代理实现通常意味着让代理知道逻辑合约的存储结构并适应它,或者让逻辑合约知道代理的存储结构并适应它。这就是为什么这种方法被称为“非结构化存储”;两个合约都不需要关心对方的结构。

实现版本之间的存储冲突

如前所述,非结构化方法避免了逻辑合约和代理之间的存储冲突。但是,逻辑合约的不同版本之间可能会发生存储冲突。在这种情况下,假设逻辑合约的第一个实现在第一个存储 slot 中存储 address public _owner,而升级后的逻辑合约在相同的第一个 slot 中存储 address public _lastContributor。当更新后的逻辑合约尝试写入 _lastContributor 变量时,它将使用之前存储 _owner 值的相同存储位置,并覆盖它!

不正确的存储保留:

|Implementation_v0   |Implementation_v1        |
|--------------------|-------------------------|
|address _owner      |address _lastContributor | <=== 存储冲突!
|mapping _balances   |address _owner           |
|uint256 _supply     |mapping _balances        |
|...                 |uint256 _supply          |
|                    |...                      |

正确的存储保留:

|Implementation_v0   |Implementation_v1        |
|--------------------|-------------------------|
|address _owner      |address _owner           |
|mapping _balances   |mapping _balances        |
|uint256 _supply     |uint256 _supply          |
|...                 |address _lastContributor | <=== 存储扩展。
|                    |...                      |

非结构化存储代理机制不能防止这种情况。用户有责任让逻辑合约的新版本扩展以前的版本,或者以其他方式保证存储层次结构始终是附加的而不是修改的。但是,OpenZeppelin Upgrades 会检测到此类冲突并适当地警告开发人员。

构造函数注意事项

在 Solidity 中,构造函数内部或全局变量声明中的代码不属于部署合约的运行时字节码。此代码仅在部署合约实例时执行一次。因此,逻辑合约构造函数中的代码将永远不会在代理状态的上下文中执行。换句话说,代理完全不知道构造函数执行的存储 trie 更改。对于代理来说,这就像它们不存在一样。(请注意,immutable 变量可以反映在代理合约中,但 应谨慎使用。)

这个问题很容易解决。逻辑合约应该将构造函数中的代码移动到常规的“初始化器”函数中,并在代理链接到此逻辑合约时调用此函数。需要特别注意此初始化器函数,以便它只能被调用一次,这是一般编程中构造函数的属性之一。

这就是为什么当我们使用 OpenZeppelin Upgrades 创建代理时,你可以提供初始化器函数的名称并传递参数。

为了确保 initialize 函数只能被调用一次,使用了一个简单的修饰符。OpenZeppelin Upgrades 通过可以扩展的合约提供此功能:

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract MyContract is Initializable {
    function initialize(
        address arg1,
        uint256 arg2,
        bytes memory arg3
    ) public payable initializer {
        // "构造函数" 代码...
    }
}

请注意合约如何扩展 Initializable 并实现它提供的 initializer

透明代理和函数冲突

如前几节所述,可升级的合约实例(或代理)通过将所有调用委托给逻辑合约来工作。但是,代理需要一些自己的函数,例如 upgradeTo(address) 以升级到新的实现。这就引出了一个问题:如果逻辑合约也有一个名为 upgradeTo(address) 的函数,该如何处理:在调用该函数时,调用者是想调用代理还是逻辑合约?

冲突也可能发生在具有不同名称的函数之间。合约公共 ABI 的每个函数都在字节码级别上通过 4 字节的标识符来标识。此标识符取决于函数的名称和元数,但由于它只有 4 个字节,因此两个具有不同名称的不同函数最终可能具有相同的标识符。Solidity 编译器会跟踪同一合约中何时发生这种情况,但不会跟踪跨不同合约(例如代理及其逻辑合约之间)发生冲突时的情况。阅读 这篇文章 以获取更多信息。

OpenZeppelin Upgrades 通过 加粗 透明代理 加粗 模式来处理这个问题。透明代理将根据调用者地址(即 msg.sender)来决定哪些调用被委托给底层逻辑合约:

  • 如果调用者是代理的管理员(有权升级代理的地址),则代理将 加粗 加粗 委托任何调用,并且只会响应它理解的任何消息。

  • 如果调用者是任何其他地址,则代理将 加粗 总是 加粗 委托调用,无论它是否与代理的某个函数匹配。

假设一个代理具有 owner()upgradeTo() 函数,该代理将调用委托给具有 owner()transfer() 函数的 ERC20 合约,下表涵盖了所有情况:

msg.sender owner() upgradeTo() transfer()
Owner 返回 proxy.owner() 返回 proxy.upgradeTo() 失败
Other 返回 erc20.owner() 失败 返回 erc20.transfer()

幸运的是,OpenZeppelin Upgrades 考虑到了这种情况,并为每个透明代理使用了一个中间的 ProxyAdmin 合约。即使你从节点的默认帐户调用 deploy 命令,ProxyAdmin 合约也将是透明代理的实际管理员。这意味着你将能够从节点的任何帐户与代理进行交互,而无需担心透明代理模式的细微差别。只有从 Solidity 创建代理的高级用户才需要了解透明代理模式。

总结

任何使用可升级合约的开发人员都应该熟悉本文中描述的代理方式。最后,这个概念非常简单,OpenZeppelin Upgrades 旨在封装所有代理机制,从而最大限度地减少你在开发项目时需要记住的事情的数量。这一切都归结为以下列表:

  • 对代理是什么有一个基本的了解

  • 始终扩展存储而不是修改它

  • 确保你的合约使用初始化器函数而不是构造函数

此外,当其中一个项目出现问题时,OpenZeppelin Upgrades 会通知你。

← 编写可升级的合约

常见问题解答 →

  • 原文链接: docs.openzeppelin.com/up...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
OpenZeppelin
OpenZeppelin
江湖只有他的大名,没有他的介绍。