本文介绍了“非结构化存储”代理模式,它是 OpenZeppelin 升级的基本构建块。
如需更深入的阅读,请参阅[我们的代理模式博客文章],其中讨论了对代理的需求,深入探讨了有关该主题的更多技术细节,详细说明了考虑用于 OpenZeppelin 升级的其他可能的代理模式等。
根据设计,智能合约是不可变的。另一方面,软件质量在很大程度上取决于升级和修补源代码以生成迭代版本的能力。尽管基于区块链的软件从技术的不变性中获益匪浅,但修复错误和潜在的产品改进仍然需要一定程度的可变性。OpenZeppelin Upgrades 通过为智能合约提供易于使用、简单、健壮和可选的升级机制来解决这一明显的矛盾,该机制可以由任何类型的治理控制,无论是多重签名钱包、简单地址还是复杂的 DAO。
基本思想是使用代理进行升级。第一个合约是一个简单的包装器或“proxy”,用户可以直接与之交互,并负责将交易转发到包含逻辑的第二个合约。要理解的关键概念是可以在代理或接入点永远不变的情况下替换逻辑合约。这两个合约仍然是不可变的,因为它们的代码不能改变,但是逻辑合约可以简单地被另一个合约交换。因此,包装器可以指向不同的逻辑实现,并且在这样做时,软件被“upgraded”。
User ---- tx ---> Proxy ----------> Implementation_v0
|
------------> Implementation_v1
|
------------> Implementation_v2
代理需要解决的最直接的问题是代理如何公开逻辑合约的整个接口,而不需要对整个逻辑合约的接口进行一对一的映射。这将难以维护,容易出错,并且会使接口本身无法升级。因此,需要动态转发机制。下面的代码介绍了这种机制的基础知识:
assembly {
let ptr := mload(0x40)
// (1) copy incoming call data
calldatacopy(ptr, 0, calldatasize)
// (2) forward call to logic contract
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
let size := returndatasize
// (3) retrieve return data
returndatacopy(ptr, 0, size)
// (4) forward return data back to caller
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
此代码可以放在代理的回退函数中,并将对具有任何参数集的任何函数的任何调用转发到逻辑合约,而无需知道任何特别是逻辑合约的接口。本质上,(1)将调用数据复制到内存中,(2)将调用转发到逻辑合约,(3)检索从调用到逻辑合约的返回数据,以及(4)转发返回的数据回到调用者。
需要注意的一件非常重要的事情是,代码使用了 EVM 的委托调用操作码,它在调用者状态的上下文中执行被调用者的代码。也就是说,逻辑合约控制着代理的状态,逻辑合约的状态是没有意义的。因此,代理不仅在逻辑合约之间转发交易,而且还代表交易对的状态。状态在代理中,逻辑在代理指向的特定实现中。
使用代理时很快就会出现的一个问题与变量在代理合约中的存储方式有关。 假设代理将逻辑合约的地址存储在它的唯一变量address public _implementation
中。现在,假设逻辑合约是一个基本令牌,其第一个变量是address public _owner
。这两个变量的大小均为 32 字节,据 EVM 所知,它们占据代理调用的结果执行流的第一个槽。当逻辑合约写入 _owner
时,它是在代理状态的范围内进行的,实际上是写入 _implementation
。这个问题可以称为“storage collision”(存储冲突)。
|Proxy |Implementation |
|--------------------------|-------------------------|
|address _implementation |address _owner | <=== Storage collision!
|... |mapping _balances |
| |uint256 _supply |
| |... |
有很多方法可以克服这个问题,OpenZeppelin Upgrades 实现的“unstructured storage”(非结构化存储)方法的工作原理如下。它没有将 _implementation
地址存储在代理的第一个存储槽中,而是选择了一个伪随机槽。这个插槽足够随机,逻辑合约在同一个插槽中声明变量的概率可以忽略不计。在代理存储中随机化插槽位置的相同原则用于代理可能具有的任何其他变量,例如管理地址(允许更新 _implementation
的值)等等。
|Proxy |Implementation |
|--------------------------|-------------------------|
|... |address _owner |
|... |mapping _balances |
|... |uint256 _supply |
|... |... |
|... | |
|... | |
|... | |
|... | |
|address _implementation | | <=== Randomized slot.
|... | |
|... | |
遵循EIP 1967的如何实现随机存储的示例:
bytes32 private constant implementationPosition = bytes32 (uint256(
keccak256('eip1967.proxy.implementation')) - 1
));
因此,逻辑合约不需要关心覆盖代理的任何变量。其他面临这个问题的代理实现通常意味着让代理知道逻辑合约的存储结构并适应它,或者让逻辑合约知道代理的存储结构并适应它。这就是为什么这种方法被称为“unstructured storage”(非结构化存储);两个合约都不需要关心另一个合约的结构。
如前所述,非结构化方法避免了逻辑合约和代理之间的存储冲突。但是,不同版本的逻辑合约之间可能会发生存储冲突。在这种情况下,假设逻辑合约的第一个实现将 address public _owner
存储在第一个存储槽,升级后的逻辑合约将address public _lastContributor
存储在相同的第一个槽。当更新的逻辑合约尝试写入 _lastContributor
变量时,它将使用与存储 _owner
的先前值相同的存储位置,并覆盖它!
不正确的存储保存:
|Implementation_v0 |Implementation_v1 |
|--------------------|-------------------------|
|address _owner |address _lastContributor | <=== Storage collision!
|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 | <=== Storage extension.
| |... |
非结构化存储代理机制无法防范这种情况。由用户决定是否让新版本的逻辑合约扩展以前的版本,或者以其他方式保证存储层次结构始终附加但不被修改。但是,OpenZeppelin Upgrades 会检测到此类冲突并适当地警告开发人员。
在 Solidity 中,构造函数内的代码或全局变量声明的一部分不是已部署合约的运行时字节码的一部分。此代码仅在部署合约实例时执行一次。因此,逻辑合约的构造函数中的代码永远不会在代理状态的上下文中执行。换句话说,代理完全没有注意到构造函数的存在。就好像他们不在代理那里一样。
不过问题很容易解决。逻辑合约应该将构造函数中的代码移动到一个常规的“初始化”函数中,并在代理链接到这个逻辑合约时调用这个函数。需要特别注意这个初始化函数,使其只能被调用一次,这是一般编程中构造函数的属性之一。
这就是为什么当我们使用 OpenZeppelin Upgrades 创建代理时,您可以提供初始化函数的名称并传递参数。
为了确保initialize
函数只能被调用一次,使用了一个简单的修饰符。OpenZeppelin Upgrades 通过可扩展的合约提供此功能:
// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts-upgradeable/proxy/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 处理这个问题的方式是通过透明代理模式。透明代理将根据调用者地址(即 msg.sender)决定将哪些调用委托给底层逻辑合约:
msg.sender owner() upgradeto() transfer() Owner returns proxy.owner() returns proxy.upgradeTo() fails Other returns erc20.owner() fails returns erc20.transfer()
幸运的是,OpenZeppelin Upgrades 考虑到了这种情况,并创建了一个中介 ProxyAdmin 合约,负责您通过 Upgrades 插件创建的所有代理。即使您从节点的默认帐户调用 deploy 命令,ProxyAdmin 合约也将是您所有代理的实际管理员。这意味着您将能够与来自任何节点帐户的代理进行交互,而不必担心透明代理模式的细微差别。只有从 Solidity 创建代理的高级用户才需要了解透明代理模式。
任何使用可升级合约的开发人员都应该熟悉本文中描述的代理。最后,这个概念非常简单,OpenZeppelin Upgrades 旨在封装所有代理机制,从而将开发项目时需要记住的事情减少到绝对最少。这一切都归结为以下列表:
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!