代理升级模式
本文介绍了“非结构化存储”代理模式,它是 OpenZeppelin Upgrades 的基本组成部分。
想要更深入的阅读,请查看 我们的代理模式博客文章,其中讨论了对代理的需求,深入探讨了该主题的技术细节,详细阐述了 OpenZeppelin Upgrades 考虑的其他可能的代理模式,等等。 |
为什么升级合约?
从设计上讲,智能合约是不可变的。另一方面,软件质量在很大程度上取决于升级和修补源代码以生成迭代版本的能力。 即使基于区块链的软件从技术的不可变性中获益匪浅,但仍然需要一定程度的可变性来进行错误修复和潜在的产品改进。 OpenZeppelin Upgrades 通过为智能合约提供易于使用、简单、稳健和可选的升级机制来解决这一明显的矛盾,该机制可以由任何类型的治理来控制,无论是多重签名钱包、简单地址还是复杂的 DAO。
通过代理模式进行升级
基本思想是使用代理进行升级。 第一个合约是一个简单的包装器或“代理”,用户直接与之交互,并负责在第二个合约之间转发交易,第二个合约包含逻辑。要理解的关键概念是可以替换逻辑合约,而代理或访问点永远不会更改。 这两个合约仍然是不可变的,因为它们的代码无法更改,但逻辑合约可以简单地被另一个合约替换。 这样,包装器可以指向不同的逻辑实现,并且通过这样做,软件被“升级”。
User ---- tx ---> Proxy ----------> Implementation_v0 | ------------> Implementation_v1 | ------------> Implementation_v2
代理转发
代理需要解决的最直接的问题是如何在不需要逻辑合约整个接口的一对一映射的情况下,公开逻辑合约的整个接口。 这将难以维护,容易出错,并且会使接口本身不可升级。因此,需要动态转发机制。 下面的代码介绍了这种机制的基础知识:
// This code is for "illustration" purposes. To implement this functionality in production it
// is recommended to use the `Proxy` contract from the `@openzeppelin/contracts` library.
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.2/contracts/proxy/Proxy.sol
assembly {
// (1) copy incoming call data
calldatacopy(0, 0, calldatasize())
// (2) forward call to logic contract
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
// (3) retrieve return data
returndatacopy(0, 0, returndatasize())
// (4) forward return data back to caller
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
此代码可以放在代理的 fallback 函数中,并将任何带有任何参数集的调用转发到逻辑合约,而无需它知道逻辑合约接口的任何特定信息。 本质上,(1) calldata
被复制到内存,(2) 调用被转发到逻辑合约,(3) 从调用逻辑合约返回的数据被检索,并且 (4) 返回的数据被转发回调用者。
非常重要的一点是,该代码使用了 EVM 的 delegatecall
操作码,该操作码在调用者的状态上下文中执行被调用者的代码。 也就是说,逻辑合约控制代理的状态,而逻辑合约的状态没有意义。 因此,代理不仅将交易转发到逻辑合约并从逻辑合约转发交易,而且还表示该对的状态。 状态在代理中,逻辑在代理指向的特定实现中。
非结构化存储代理
使用代理时很快出现的一个问题与变量在代理合约中的存储方式有关。 假设代理将逻辑合约的地址存储在其唯一的变量 address public _implementation;
中。 现在,假设逻辑合约是一个基本 token,其第一个变量是 address public _owner
。 两个变量的大小均为 32 字节,并且就 EVM 所知,它们占据了代理调用的最终执行流程的第一个插槽。 当逻辑合约写入 _owner
时,它在代理状态的范围内执行此操作,实际上是写入 _implementation
。 此问题可以称为“存储冲突”。
|Proxy |Implementation | |--------------------------|-------------------------| |address _implementation |address _owner | <=== 存储冲突! |... |mapping _balances | | |uint256 _supply | | |... |
有很多方法可以克服这个问题,OpenZeppelin Upgrades 实现的“非结构化存储”方法的工作方式如下。 它并没有将 _implementation
地址存储在代理的第一个存储槽中,而是选择了一个伪随机插槽。 此插槽具有足够的随机性,以至于逻辑合约在同一插槽中声明变量的概率可以忽略不计。 代理可能拥有的任何其他变量(例如,允许更新 _implementation
值的管理员地址等)都使用相同的随机化槽位置的原理。
|Proxy |Implementation | |--------------------------|-------------------------| |... |address _owner | |... |mapping _balances | |... |uint256 _supply | |... |... | |... | | |... | | |... | | |... | | |address _implementation | | <=== 随机槽。 |... | | |... | |
以下是根据 EIP 1967 实现随机存储的示例:
bytes32 private constant implementationPosition = bytes32(uint256(
keccak256('eip1967.proxy.implementation')) - 1
));
因此,逻辑合约不必担心覆盖任何代理的变量。 面临此问题的其他代理实现通常意味着代理了解逻辑合约的存储结构并适应它,或者逻辑合约了解代理的存储结构并适应它。 这就是为什么这种方法被称为“非结构化存储”; 两个合约都不需要关心对方的结构。
实现版本之间的存储冲突
如前所述,非结构化方法避免了逻辑合约和代理之间的存储冲突。 但是,可能会发生逻辑合约的不同版本之间的存储冲突。 在这种情况下,假设逻辑合约的第一个实现在第一个存储槽中存储 address public _owner
,而升级后的逻辑合约在同一第一个槽中存储 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 {
// "constructor" code...
}
}
请注意合约如何扩展 Initializable
并实现它提供的 initializer
。
透明代理和函数冲突
如前几节所述,可升级的合约实例(或代理)通过将所有调用委托给逻辑合约来工作。 但是,代理需要一些自己的函数,例如 upgradeTo(address)
以升级到新的实现。 这就引出了一个问题,如果逻辑合约也有一个名为 upgradeTo(address)
的函数,该如何处理:在调用该函数时,调用者是打算调用代理还是逻辑合约?
冲突也可能发生在具有不同名称的函数之间。 合约公共 ABI 的每个函数在字节码级别都由一个 4 字节的标识符标识。 此标识符取决于函数的名称和元数,但由于它只有 4 个字节,因此两个具有不同名称的不同函数可能最终具有相同的标识符。 Solidity 编译器会跟踪同一合约中发生这种情况,但不会跟踪跨不同合约发生的冲突,例如代理与其逻辑合约之间。 阅读 本文以获取更多信息。 |
OpenZeppelin Upgrades 处理此问题的方式是通过 transparent proxy 模式。 透明代理将根据调用者地址(即 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 创建代理的高级用户才需要了解透明代理模式。