本文翻译自Zeppelin于2018年发表的关于《代理模式》文章。所有权归原文作者所有。
原文链接:https://blog.openzeppelin.com/proxy-patterns/
以太坊的最大优势之一是其公共账本内交易记录的不可篡改性,这些交易包括Token的转移,合约的部署以及合约交易。以太坊网络上的任何节点都可以验证每笔交易的有效性和状态,从而使以太坊成为一个非常强大的去中心化系统。
但最大的缺点是,智能合约一旦部署后,则无法更改合约源码。中心化应用程序(例如Facebook或Airbnb)的开发人员会经常对程序进行更新,修复bug或引入新功能。而这种方式在以太坊上是不可能做到的。
还记得著名的Parity Wallet 事件,黑客盗取了150000个ETH,在这次的攻击中,Parity multisig钱包中一个合约的漏洞被黑客利用,盗取了钱包中的资金。在黑客攻击过程中,我们唯一能做的就是利用相同的漏洞,比黑客更快速的将钱包中的资金进行转移,并在事后归还给所有者。
如果有一种方法可以在智能合约部署后,更新源代码……
引入代理模式
虽然无法更新已部署的智能合约代码,但是可以通过设置一个代理合约架构,进而部署新的合约,以实现合约升级的目的。
代理模式使得所有消息调用都通过代理合约,代理合约会将调用请求重定向到最新部署的合约中。如要升级时,将升级后新合约地址更新到代理合约中即可。
Zeppelin在实现zeppelin_os的过程中一直在研究几种代理模式。探索了三种代理模式:
- 继承存储
- 永久存储
- 非结构化存储
这三种模式底层都依赖delegatecalls
来实现。虽然Solidity提供了delegatecall
方法,但它仅在调用成功后返回true / false,无法管理返回的数据。
在深入研究之前,需要先理解两个重要的概念:
- 当调用的方法在合约中不存在时,合约会调用
fallback
函数。可以编写fallback
函数的逻辑处理这种情况。代理合约使用自定义的fallback
函数将调用请求重定向到其他合同中。 - 每当合约A将调用代理到另一个合同B时,它都会在合约A的上下文中执行合约B的代码。这意味着将保留msg.value和msg.sender值,并且每次存储修改都会影响合约A。
所有代理模式都继承了Zeppelin’s Proxy contract,该合约实现了自己的代理调用函数,该函数返回调用逻辑合约的值。如果您打算使用Zeppelin的代理合约代码,需要详细了解代理合约代码。让我们探索它是如何工作的,并了解它用于实现代理的汇编操作码。(参考Solidity的Assembly文档以获取更多信息)
[assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
let size := returndatasize
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
为了将调用请求代理到给另一个合约中,我们必须将代理合约收到的msg.data传递给目标合约。由于msg.data的类型为bytes,大小是不固定的,数据大小存储在msg.data的第一个字长(32个字节)中。如果我们只想提取实际数据,则需要跳过前32字节,从msg.data的0x20
(32个字节)位置开始。这里,我们将利用两个操作码来执行该操作。使用calldatasize
获得msg.data的大小,使用calldatacopy
将其复制到ptr
变量中。
注意如何初始化ptr
变量。在Solidity中,内存插槽0x40
位置是比较特殊的,它包含了下一个可用的空闲内存指针的值。每次将变量直接保存到内存时,都应通过查询0x40
位置的值,来确定变量保存在内存的位置。现在,可以使用calldatacopy
用来将大小为calldatasize
的数据复制到ptr
中。
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
接下来看一下汇编模块中delegatecall
操作码:
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
参数:
-
gas
我们传递执行合约所需要燃料 -
_impl
所请求的目标合约地址 -
ptr
请求数据在内存中的起始位置 -
calldatasize
请求数据的大小。 -
0
用于表示目标合约的返回值。这是未使用的,因为此时我们尚不知道返回数据的大小,因此无法将其分配给变量。之后我们可以使用returndata
操作码访问此信息 -
0
表示目标合约返回值的大小。这是未使用的,因为在调用目标合约之前,我们是无法知道返回值的大小。之后我们可以通过returndatasize
操作码来获得该值
下一行,使用returndatasize
操作码获取返回值的大小
let size := returndatasize
然后,我们使用returndatacopy
操作码将返回的数据拷贝到ptr
变量中。
returndatacopy(ptr, 0, size)
最后,switch语句返回的数据或者抛出异常。
至此,我们现在有一种方法可以从目标合约中获取到返回结果。
现在我们了解了代理合约的工作原理,让我们看一下Zeppelin提出的三种模式:使用继承存储,非结构化存储和永久存储来实现合约的可升级。
这三种模式都用来解决同一个难题:如何确保目标合约不会覆盖代理合约中用于升级的状态变量。
所有代理模式的主要关注点是如何处理存储分配。请记住,由于我们将一个合约用于存储,而将另一个合约用于逻辑处理,因此任何一个合约都可能覆盖已使用的存储插槽。这意味着,如果代理合约具有状态变量以跟踪某个存储插槽中的最新逻辑合约地址,而该逻辑合约不知道该变量,则该逻辑合约可能会在同一插槽中存储一些其他数据,从而覆盖代理的关键信息。Zeppelin的三种模式提供了不同的方法来构建,以使合约可以通过代理进行升级。
使用继承存储实现可升级
继承存储方式需要逻辑合约包含代理合约所需的存储结构。代理和逻辑合约都继承相同的存储结构,以确保两者都存储必要的代理状态变量。
对于这种方式,我们使用Registry
合约来跟踪逻辑合同的不同版本。为了升级到新的逻辑合同,开发者需要在注册合约中将新升级的合约进行注册,并要求代理升级到新合约。需要注意的是,拥有注册合约并不会影响存储机制。实际上,本文讲述的这几种存储模式都可以实现该机制。
如何初始化
- 部署
Registry
合约 - 部署初始版本目标合约(v1)。确保它继承了可升级合约
- 将初始版本的目标合约地址注册到
Registry
合约 - 请求
Registry
合约,创建一个UpgradeabilityProxy
实例 - 请求
UpgradeabilityProxy
,升级到目标合约的初始版本
如何升级
- 部署从初始版本继承的新版本合约(v2),并确保新版本合约保留代理的存储结构和初始版本合约的存储结构。
- 将新版本的合约注册到
Registry
- 请求
UpgradeabilityProxy
,将目标合约升级为新版本。
重要要点
我们仍然可以通过UpgradeabilityProxy
合约,来调用新版本目标合约引入的新方法或新变量。
使用永久存储实现可升级
在Eternal Storage模式中,存储结构是在单独的合约中定义,代理合约和逻辑合约都继承存储合约。存储合约包含逻辑合约所需的所有状态变量,同时,代理合约也能够识别这些状态变量,因此代理合约在定义升级所需要的状态变量时,不必担心所定义的状态变量会被覆盖。请注意,逻辑合约的后续版本均不应定义任何其他状态变量。逻辑合约的所有版本都必须始终使用最开始定义存储结构。
Zeppelin在实现这种存储代理模式时,引入了代理所有权的概念。只有代理所有者有权将新版本合约写入代理合约中,或者将所有权进行移交。
如何初始化
- 部署
EternalStorageProxy
合约 - 部署初始版本目标合约(v1)
- 调用
EternalStorageProxy
合约,将初始版本的目标合约地址注册到代理合约中 - 如果您的逻辑合约依赖构造函数来设置一些初始状态,则在注册到代理合约之后必须重新初始化,这是因为代理的存储不知道这些值。
EternalStorageProxy
具有upgradeToAndCall
方法专门用于在代理合约中调用升级后目标合约,进行目标合约的初始参数的赋值。
如何升级
- 部署(v2)版本的目标合约,确保其拥有永久的存储结构。
- 调用
EternalStorageProxy
,将合约升级到新版本。
重要要点
新版本合约可以升级现有合约的方法或引入新的方法,但是不能引入新的状态变量。
使用非结构化存储实现可升级
非结构化存储模式类似继承存储模式,但并不需要目标合约继承与升级相关的任何状态变量。此模式使用代理合约中定义的非结构化存储插槽来保存升级所需的数据。
在代理合约中,我们定义了一个常量变量,在对它进行Hash时,应提供足够随机的存储位置来存储代理合约调用逻辑合约的地址。
bytes32 private constant implementationPosition =
keccak256("org.zeppelinos.proxy.implementation");
由于常量不会占用存储插槽,因此不必担心implementationPosition
被目标合约意外覆盖。由于Solidity状态变量存储的规定,目标合约中定义的其他内容使用此存储插槽冲突的可能性极小。
通过这种模式,逻辑合约不需要知道代理合约的存储结构,但是所有未来的逻辑合约都必须继承其初始版本定义的存储变量。就像在继承存储模式中一样,将来升级的目标合约可以升级现有功能以及引入新功能和新存储变量。
Zeppelin在实现这种存储代理模式时,引入了代理所有权的概念。只有代理所有者有权将新版本合约写入代理合约中,或者将所有权进行移交。
如何初始化
- 部署
OwnedUpgradeabilityProxy
合约 - 部署初始版本(v1)的目标合约
- 调用
OwnedUpgradeabilityProxy
合约将初始版本的目标合约注册到代理合约中 - 如果您的逻辑合约依赖于其构造函数来设置一些初始状态,则在注册到代理之后必须重做,因为代理的存储不知道这些值。
OwnedUpgradeabilityProxy
提供upgradeToAndCall
函数专门用于在代理合约中调用目标合约的函数,对参数进行初始化。
如何升级
- 部署(v2)版本的合约,确保它继承了先前版本中使用的状态变量。
- 调用
OwnedUpgradeabilityProxy
,将目标合约升级到新版本。
重要要点
这种方式最实用,目标合约与代理合约耦合性最低。
关于合约升级
重要提示:如果您的逻辑合约依赖于其构造函数来设置一些初始状态,则在注册到代理合约后需要重新初始化该参数。例如,目标合约继承Zeppelin的Ownable
,还会继承Ownable
的构造函数,该构造函数设置了创建合约时所有者的地址。当您注册到代理合约,从代理合约调用目标合约时,从代理合约的的角度来看所有者的地址并没有初始化。
解决该问题的常见方式是,代理合约调用目标合约上的initialize方法。initialize方法实现了构造函数中的逻辑。除此之外,还需要一个标识,使得某些初始变量只能够被赋值一次。
您的目标合约应如下所示:
[contract Token is Ownable {
...
bool internal _initialized;
function initialize(address owner) public {
require(!_initialized);
setOwner(owner);
_initialized = true;
}
...
}
根据不同的部署策略,可以使用专门的部署合约来部署所有合约,也可以将代理合约和目标合约分开部署,然后使用如下upgradeToAndCall
方法所示将目标合约注册到代理合约中 :
[const initializeData = encodeCall('initialize', ['address'], [tokenOwner]) await proxy.upgradeToAndCall(logicContract.address, initializeData, {
from: proxyOwner
})
结论
代理模式的概念已经存在了一段时间,但由于其复杂性,担心引入安全漏洞以及绕过区块链不可篡改而引起争论,尚未得到广泛采用。过去的解决方案也相当僵化,使得目标合约可以修改和添加的内容受到严格限制。但是,从开发人员的角度来看,很显然需要升级合约的功能。Zeppelin为他们探索的三种代理模式提供了代码和测试,以帮助开发人员设计在其项目中引入合约的可升级性。
尽管代理模式的概念并不是什么新概念,但它的采用仍为时过早,令人兴奋的是,看到这种范式可以实现更高级的DApp架构。如果您使用代理模式构建了某些内容,请在Twitter上让我和Zeppelin知道,然后加入Zeppelin slack channel以在此处进行展示
进一步阅读
Zeppelin团队目前正在采用非结构化存储方式,作为Zeppelin在EVM之上实现去中心化平台和工具zeppelin_os的部分功能。非结构化存储方式具有巨大的优势,它通过引入一种新颖的方式来维护代理所需的存储变量,而不用侵入目标合约。感兴趣的读者可以阅读有关Zeppelin 在即将发布的zeppelin_os Kernel发行版中使用非结构化存储的更多信息。
Zeppelin还在Eternal Storage 技术博客发布了详细的介绍文章。
一年多以前,Aragon和Zeppelin 联手在代理库上写了两个博客文章,这些文章可以在这里和这里找到。
Arachnid、Nick Johnson或go-ethereum的核心开发人员、ENS的首席开发人员在两年多以前出版的核心文章中发表了对Upgradeable&Dispatcher合约的看法。
如果您希望构建简单的东西,并且不会在未来的合约有巨大的变化,则可以参考这个非常简单的示例。
Solidity文档总是很有帮助,建议您查看Solidity的delegate call function和assembly opcodes.
上述文章中的所有图都是使用此Figma file上制作,您可以随意复制到自己的图中
2018年12月更新:自从本文最初发表以来,我们一直在ZeppelinOS努力改进我们在库中使用的代理模式。在此处阅读最新信息,并查看ZeppelinOS审核实现的那些模式。