以太坊 智能合约升级
以太坊的最大优势之一是其公共账本内交易记录的不可篡改性,这些交易包括 Token 的转移,合约的部署以及合约交易。以太坊网络上的任何节点都可以验证每笔交易的有效性和状态,从而使以太坊成为一个非常强大的去中心化系统。
但最大的缺点是,智能合约一旦部署后,则无法更改合约源码。中心化应用程序的开发人员会经常对程序进行更新,修复 bug 或引入新功能。而这种方式在以太坊上是不可能做到的。
著名的 Parity Wallet 事件,黑客盗取了 150000 个 ETH,在这次的攻击中,Parity multisig 钱包中一个合约的漏洞被黑客利用,盗取了钱包中的资金。在黑客攻击过程中,我们唯一能做的就是利用相同的漏洞,比黑客更快速的将钱包中的资金进行转移,并在事后归还给所有者。
如果有一种方法可以在智能合约部署后,更新源代码……
一、代理模式
虽然无法更新已部署的智能合约代码,但是可以通过设置一个代理合约架构,进而部署新的合约,以实现合约升级的目的。
代理模式使得所有消息调用都通过代理合约,代理合约会将调用请求重定向到最新部署的合约中。如要升级时,将升级后新合约地址更新到代理合约中即可。
代理底层由 delegatecall 和 fallback 函数来实现:
- 当调用的方法在合约中不存在时,合约会调用 fallback 函数。可以编写 fallback 函数的逻辑处理这种情况。代理合约使用自定义的 fallback 函数将调用请求重定向到逻辑合约中。
- 每当合约 A 通过 delegatecall 到另一个合约 B 时,它都会在合约 A 的上下文中执行合约 B 的代码。这意味着将保留 msg.value 和 msg.sender 值,并且每次存储修改的是合约 A。
代理的核心实现代码(使用强大的汇编代码来解析参数和提供返回值):
pragma solidity ^0.6.6;
abstract contract Proxy {
function implementation() public virtual view returns (address);
fallback() external {
address _impl = implementation();
require(_impl != address(0));
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 位置的值,来确定变量保存在内存的位置。
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
接下来看一下汇编模块中 delegatecall 操作码:
let result := delegatecall(gas(), target, ptr, calldatasize(), 0, 0)
参数:
gas()
传递执行合约所需要燃料target
所请求的目标合约地址ptr
请求数据在内存中的起始位置calldatasize()
请求数据的大小。0
用于表示目标合约的返回值。这是未使用的,因为此时我们尚不知道返回数据的大小,因此无法将其分配给变量。之后我们可以使用 returndata 操作码访问此信息0
表示目标合约返回值的大小。这是未使用的,因为在调用目标合约之前,我们是无法知道返回值的大小。之后我们可以通过 returndatasize 操作码来获得该值
下一行,使用 returndatasize 操作码获取返回值的大小
let size := returndatasize()
然后,我们使用 returndatacopy 操作码将返回的数据拷贝到 ptr 变量中。
returndatacopy(ptr, 0, size)
最后,switch 语句返回的数据或者抛出异常。
因为使用了 delegatecall,所以需要注意合约的存储分配。由于我们将一个合约用于存储,而将另一个合约用于逻辑处理,因此任何一个合约都可能覆盖已使用的存储插槽。这意味着,如果代理合约具有状态变量以跟踪某个存储插槽中的最新逻辑合约地址,而该逻辑合约不知道该变量,则该逻辑合约可能会在同一插槽中存储一些其他数据,从而覆盖代理的关键信息。
可以使用以下三种代理模式来实现合约的可升级:
- 继承存储
- 永久存储
- 非结构化存储
二、继承存储
继承存储方式需要逻辑合约包含代理合约所需的存储结构。代理和逻辑合约都继承相同的存储结构,以确保两者都存储必要的代理状态变量。对于这种方式,我们使用 Registry 合约来跟踪逻辑合同的不同版本。为了升级到新的逻辑合同,开发者需要在注册合约中将新升级的合约进行注册,并要求代理升级到新合约。
interface IRegistry {
event ProxyCreated(address proxy);
event VersionAdded(string version, address implementation);
function addVersion(string calldata version, address implementation) external;
function getVersion(string calldata version) external view returns (address);
}
contract Registry is IRegistry {
mapping (string => address) internal versions;
function addVersion(string memory version, address implementation) public override {
require(versions[version] == address(0));
versions[version] = implementation;
emit VersionAdded(version, implementation);
}
function getVersion(string memory version) public override view returns (address) {
return versions[version];
}
function createProxy(string memory version) public payable returns (UpgradeabilityProxy) {
UpgradeabilityProxy proxy = new UpgradeabilityProxy(version);
Upgradeable(address(proxy)).initialize{value:msg.value}(msg.sender);
ProxyCreated(address(proxy));
return proxy;
}
}
contract UpgradeabilityStorage {
IRegistry internal registry;
address internal _implementation;
}
contract UpgradeabilityProxy is UpgradeabilityStorage,Proxy {
constructor(string memory _version) public {
registry = IRegistry(msg.sender);
upgradeTo(_version);
}
function implementation() public override view returns (address) {
return _implementation;
}
function upgradeTo(string memory _version) public {
_implementation = registry.getVersion(_version);
}
}
abstract contract Upgradeable is UpgradeabilityStorage {
function initialize(address sender) public virtual payable;
}
如何初始化
- 部署
Registry
合约 - 部署初始版本目标合约(v1),确保它继承了
Upgradeable
合约 - 将初始版本目标合约的地址注册到
Registry
合约 - 调用
Registry
合约创建一个UpgradeabilityProxy
代理合约实例
如何升级
- 部署从初始版本继承的新版本合约(v2),并确保新版本合约保留初始版本合约的存储结构。
- 将新版本的合约注册到 Registry
- 调用
UpgradeabilityProxy
,将目标合约升级为新版本。
重要要点 - 数据实际是保存在
UpgradeabilityProxy
中的 - 通过升级合约,可以引入新的方法或状态变量。
三、永久存储
在永久存储模式中,存储结构是在单独的合约中定义,代理合约和逻辑合约都继承存储合约。存储合约包含逻辑合约所需的所有状态变量,同时,代理合约也能够识别这些状态变量,因此代理合约在定义升级所需要的状态变量时,不必担心所定义的状态变量会被覆盖。但是逻辑合约的后续版本均不应定义任何其他状态变量,逻辑合约的所有版本都必须始终使用最开始定义存储结构。只有代理所有者有权将新版本合约写入代理合约中,或者将所有权进行移交。
contract EternalStorage {
mapping(bytes32 => uint256) uintStorage;
mapping(bytes32 => string) stringStorage;
mapping(bytes32 => address) addressStorage;
mapping(bytes32 => bytes) bytesStorage;
mapping(bytes32 => bool) boolStorage;
mapping(bytes32 => int256) intStorage;
}
contract UpgradeabilityStorage {
string _version;
address _implementation;
address _upgradeabilityOwner;
}
contract UpgradeabilityProxy is Proxy, UpgradeabilityStorage {
event Upgraded(string version, address indexed implementation);
event ProxyOwnershipTransferred(address previousOwner, address newOwner);
function version() public view returns (string memory) {
return _version;
}
function implementation() public override view returns (address) {
return _implementation;
}
function upgradeabilityOwner() public view returns (address) {
return _upgradeabilityOwner;
}
function setUpgradeabilityOwner(address newUpgradeabilityOwner) public {
_upgradeabilityOwner = newUpgradeabilityOwner;
}
function _upgradeTo(string memory newVersion, address newImplementation) internal {
require(_implementation != newImplementation);
_version = newVersion;
_implementation = newImplementation;
emit Upgraded(newVersion, newImplementation);
}
}
contract OwnedUpgradeabilityProxy is UpgradeabilityProxy {
constructor() public {
setUpgradeabilityOwner(msg.sender);
}
modifier onlyProxyOwner() {
require(msg.sender == proxyOwner());
_;
}
function proxyOwner() public view returns (address) {
return upgradeabilityOwner();
}
function transferProxyOwnership(address newOwner) public onlyProxyOwner {
require(newOwner != address(0));
ProxyOwnershipTransferred(proxyOwner(), newOwner);
setUpgradeabilityOwner(newOwner);
}
function upgradeTo(string memory version, address implementation) public onlyProxyOwner {
_upgradeTo(version, implementation);
}
}
contract EternalStorageProxy is EternalStorage, OwnedUpgradeabilityProxy {}
contract V1 is EternalStorage {
function balanceOf(address owner) public view returns (uint256) {
return uintStorage[keccak256(abi.encodePacked("balance", owner))];
}
function setBalance(address owner, uint256 balance) public {
uintStorage[keccak256(abi.encodePacked(owner))] = balance;
}
}
如何初始化
- 部署
EternalStorageProxy
合约 - 部署初始版本目标合约(v1), 继承自
EternalStorage
合约 - 调用
EternalStorageProxy
合约,将初始版本的目标合约地址注册到代理合约中 - 如果逻辑合约依赖构造函数来设置一些初始状态,则在注册到代理合约之后必须重新初始化,这是因为代理的存储不知道这些值。
如何升级 - 部署(v2)版本的目标合约,确保其拥有相同的存储结构。
- 调用
EternalStorageProxy
,将合约升级到新版本。
重要要点 - 新版本合约可以升级现有合约的方法或引入新的方法,但是不能引入新的状态变量。
四、非结构化存储
非结构化存储模式类似继承存储模式,但并不需要目标合约继承与升级相关的任何状态变量。此模式使用代理合约中定义的非结构化存储插槽来保存升级所需的数据。在代理合约中,我们定义了一个常量变量,在对它进行 Hash 时,应提供足够随机的存储位置来存储代理合约调用逻辑合约的地址。
bytes32 private constant implementationPosition =
keccak256("proxy.implementation");
由于常量不会占用存储插槽,因此不必担心 implementationPosition 被目标合约意外覆盖。由于 Solidity 状态变量存储的规定,目标合约中定义的其他内容使用此存储插槽冲突的可能性极小。
通过这种模式,逻辑合约不需要知道代理合约的存储结构,但是所有未来的逻辑合约都必须继承其初始版本定义的存储变量。就像在继承存储模式中一样,将来升级的目标合约可以升级现有功能以及引入新功能和新存储变量。只有代理所有者有权将新版本合约写入代理合约中,或者将所有权进行移交。
contract UpgradeabilityProxy is Proxy {
event Upgraded(address indexed implementation);
bytes32 private constant implementationPosition = keccak256("proxy.implementation");
function implementation() public override view returns (address impl) {
bytes32 position = implementationPosition;
assembly {
impl := sload(position)
}
}
function setImplementation(address newImplementation) internal {
bytes32 position = implementationPosition;
assembly {
sstore(position, newImplementation)
}
}
function _upgradeTo(address newImplementation) internal {
address currentImplementation = implementation();
require(currentImplementation != newImplementation);
setImplementation(newImplementation);
emit Upgraded(newImplementation);
}
}
contract OwnedUpgradeabilityProxy is UpgradeabilityProxy {
event ProxyOwnershipTransferred(address previousOwner, address newOwner);
bytes32 private constant proxyOwnerPosition = keccak256("proxy.owner");
constructor() public {
setUpgradeabilityOwner(msg.sender);
}
modifier onlyProxyOwner() {
require(msg.sender == proxyOwner());
_;
}
function proxyOwner() public view returns (address owner) {
bytes32 position = proxyOwnerPosition;
assembly {
owner := sload(position)
}
}
function setUpgradeabilityOwner(address newProxyOwner) internal {
bytes32 position = proxyOwnerPosition;
assembly {
sstore(position, newProxyOwner)
}
}
function transferProxyOwnership(address newOwner) public onlyProxyOwner {
require(newOwner != address(0));
emit ProxyOwnershipTransferred(proxyOwner(), newOwner);
setUpgradeabilityOwner(newOwner);
}
function upgradeTo(address implementation) public onlyProxyOwner {
_upgradeTo(implementation);
}
}
contract V1 {
mapping (address => uint256) internal _balances;
function balanceOf(address owner) public view returns (uint256) {
return _balances[owner];
}
function setBalance(address owner, uint256 balance) public {
_balances[owner] = balance;
}
}
如何初始化
- 部署
OwnedUpgradeabilityProxy
合约 - 部署初始版本(v1)的目标合约
- 调用
OwnedUpgradeabilityProxy
合约将初始版本的目标合约注册到代理合约中 - 如果您的逻辑合约依赖于其构造函数来设置一些初始状态,则在注册到代理之后必须重做,因为代理的存储不知道这些值。
如何升级 - 部署(v2)版本的合约,确保它继承了先前版本中使用的状态变量。
- 调用
OwnedUpgradeabilityProxy
合约,将目标合约升级到新版本。
重要要点 - 这种方式最实用,目标合约与代理合约耦合性最低。