原文地址: https://ethfans.org/posts/flexible-upgradability-for-smart-contracts
以太坊智能合约具有很强的不变性,使得我们能够构建完全防篡改的应用程序,任何个人、公司或政府都不能篡改数据(信息)。每个参与者都遵循相同的规则,并且这些规则永远都不会改变。
但是,说到底,这些规则都是由人创造的。而人类总是偶然会犯一点错误的。我们不可能从第一天就看到未来发展的完整画面,并构造一个完全不需要适配或改进的完美系统。
为了平衡不变性与灵活性,我们需要一种升级部署后的去中心化应用程序的机制。在本文中,我们将介绍如何使用一些简单但有效的模式来实现这一点。
虽然我们将描述升级机制,但我们不会讨论升级是如何触发的。我们假设升级操作将由“所有者”执行。该“所有者”可以是一个单独持有的地址、一个多签名合约,或者一个复杂的去中心化自治组织(DAO)。
Zeppelin Solutions和Aragon团队已经提出了一些非常有效的升级模式。我们借鉴了 Solidity 代理库(Proxy Libraries in Solidity) (中译本见文末超链接)以及 使用永久存储升级智能合约(Smart Contract Upgradability Using Eternal Storage) 的代码。
在 Level K,我们把这些模式应用到我们的 Dapp 可升级工具箱(正在开发当中)中。该工具箱包含一些用于升级任何去中心化应用程序的核心合约。
如果你不想继续看本文了,只想看看代码,那就去吧!这篇文章的所有代码都在这里:github.com/levelkdev/upgradability-blog-post
我们假设你已经对 ERC20 代币以及使他们工作的代码有一定的了解。如果之前没有了解的话,你可以看一看 Zeppelin 的 ERC20 合约代码,从而更好地理解(相关内容)。
假设我们要部署一个名为 ShrimpCoin
的新代币。至于用途么,只能让人们自己猜想一下了。
下面的结构图展示了,ShrimpCoin
从标准代币升级为“mintable”(铸币厂)代币的样子:
所有这些都有详细解释,请往下看!
你会注意到 ShrimpCoin
是一个代理合约。这意味着当一个交易被发送(例如 transfer()
), ShrimpCoin
并不知道交易内指定函数,它会将交易代理到我们称为“委托”的合约中。
这可以通过原生 EVM 代码实现,委托调用 ( delegatecall )。从 Solidity 文档中可以看到,一个使用 delegatecall
的合约……
……可以在运行时动态地从不同地址加载代码。存储、当前地址以及余额仍然是指发起调用的合约,只是代码来自被调地址。
简单地说,这意味着, ShrimpCoin
包含了我们委托合约(TokenDelegate)的全部功能。要升级 ShrimpCoin
的功能,我们只需要通知代理使用新的委托合约(我们例子中是 MintableTokenDelegate
)。代理合约的代码可能有些晦涩难懂(这有一些 EVM 汇编代码):
pragma solidity ^0.4.18;
import "zeppelin-solidity/contracts/ownership/Ownable.sol";
contract Proxy is Ownable {
event Upgraded(address indexed implementation);
address internal _implementation;
function implementation() public view returns (address) {
return _implementation;
}
function upgradeTo(address impl) public onlyOwner {
require(_implementation != impl);
_implementation = impl;
Upgraded(impl);
}
function () payable public {
address _impl = implementation();
require(_impl != address(0));
bytes memory data = msg.data;
assembly {
let result := delegatecall(gas, _impl, add(data, 0x20), mload(data), 0, 0)
let size := returndatasize
let ptr := mload(0x40)
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
我们来看 fallback(返回)函数 function() payable public{...
,其可以用于处理所有未知功能签名的交易。在函数内部,汇编代码用于进行 delegatecall
调用。对于没有返回值的函数可以使用简单的旧版本 Solidity 实现。然而,delegatecall
调用仅返回单一值,用于表示调用成功或失败。该汇编代码块获得了代理交易的实际返回值,并返回给上层函数。
代理合约是一个 Ownable
合约,并允许预设一些可以执行 upgradeTo()
函数的所有者,这些所有者可以使用任何委托合约升级该合约。
当代理合约使用委托合约的功能时,代理合约将发生状态改变。这意味着两个合约需要定义相同的存储内存。两个合约在内存中定义的存储顺序需要、一致。
下面有一个例子用于说明本概念。假设把 Thing
设置为使用 ThingDelegate
的功能:
contract Thing is Proxy {
uint256 num;
string name = "Thing";
}
contract ThingDelegate {
uint256 n;
function incrementNum() public {
n = n + 1;
}
}
这里发生了一些有趣的事情……
虽然存储内存一致(两个合约都定义了一个 uint256
变量),但变量名(num
与 n
)并不一致。即使这些变量名不相同,但它们仍可以通过匹配存储内存编译成字节码。因此,当 Thing
代理调用 ThingDelegate
的 incrementNum()
方法时,也会在 Thing
的状态中增加 num
变量。
此外,额外存储和状态的定义在这( string name = "Thing"
,字符串类型变量name,内容为"Thing")。该存储空间不能被ThingDelegate
修改。存储的顺序在这里非常重要。如果变量name
定义在变量num
之前,那么incrementNum()
将会试图给一个字符串加一。
我们很喜欢这个模式的地方是, ThingDelegate
不需要知道 Thing
。一旦 ThingDelegate
部署完成,任何合约都可以将其作为委托使用,因此 ThingDelegate
是可以公开使用的。实际上,任意已部署的合约都可以作为委托使用,并且不需要这样定义。
让我们来看一看稍微复杂一点的 ShrimpCoin
和 TokenDelegate
功能,以及一些存储辅助(类), StorageConsumer
(存储消费者)和 StorageStateful
(存储状态):
contract ShrimpCoin is StorageConsumer, Proxy, DetailedToken {
function ShrimpCoin(KeyValueStorage storage_)
public
StorageConsumer(storage_)
{
name = "ShrimpCoin";
symbol = "SHRMP";
decimals = 18;
}
}
contract DetailedToken {
string public name;
string public symbol;
uint8 public decimals;
}
contract TokenDelegate is StorageStateful {
function totalSupply() public view returns (uint256) {
return _storage.getUint("totalSupply");
}
}
contract StorageConsumer is StorageStateful {
function StorageConsumer(KeyValueStorage storage_) public {
_storage = storage_;
}
}
contract StorageStateful {
KeyValueStorage _storage;
}
遵循与 Thing
示例相同的模式。但这里的通用状态时 KeyValueStorage
(键值存储)合约(在下一部分讲述)的地址。
需要特别强调的是,ShrimpCoin
在继承 DetailedToken
之前继承了 StorageConsumer
。如果(继承顺序)交换, TokenDelegate
将会在 getUint()
操作中使用字符串命名(string name);而不是键值存储(KeyValueStorage _storage)。这将导致交易回滚。
代理委托模式对于升级功能非常有用,但是如果我们想添加一些在原始合约中没有定义的状态呢?这就是“永恒存储”模式的由来。这种模式最初在使用 Solidity 编写可升级合约中提出。
下面是一个简化的 KeyValueStorage
(键值存储)合约:
contract KeyValueStorage {
mapping(address => mapping(bytes32 => uint256)) _uintStorage;
mapping(address => mapping(bytes32 => address)) _addressStorage;
mapping(address => mapping(bytes32 => bool)) _boolStorage;
/**** Get Methods ***********/
function getAddress(bytes32 key) public view returns (address) {
return _addressStorage[msg.sender][key];
}
function getUint(bytes32 key) public view returns (uint) {
return _uintStorage[msg.sender][key];
}
function getBool(bytes32 key) public view returns (bool) {
return _boolStorage[msg.sender][key];
}
/**** Set Methods ***********/
function setAddress(bytes32 key, address value) public {
_addressStorage[msg.sender][key] = value;
}
function setUint(bytes32 key, uint value) public {
_uintStorage[msg.sender][key] = value;
}
function setBool(bytes32 key, bool value) public {
_boolStorage[msg.sender][key] = value;
}
}
该合约定义了三个映射的 mapping 结构。用于存储 uint256
、 bool
以及 address
类型的数据。这些映射用最高级的键值是 msg.sender
,(msg.sender)是使用 set/get 函数执行写或读操作的智能合约的地址。
逻辑上,键/值存储结构如下:
_uintStorage
"totalSupply": 1000
"totalSupply": 2000
_boolStorage
"isPaused": true
"isPaused": false
在我们的例子中, msg.sender
是 ShrimpCoin
合约地址,而键值可能形如 "totalSupply"
。
由于我们正关闭 msg.sender
,全部的键值对的范围均被限定在发送者合约内。一个合约不能操纵其他合约的存储数据。这意味着在 KeyValueStorage
合约部署之后,它对任何合约开放使用。
我们可以使用 KeyValueStorage
提供的 getter 和 setter 方法读取或设置状态值。
可以调用可约使用如下代码设置 totalSupply
的值为 1000
:
_storage.setUint("totalSupply", 1000);
我们还可以设置更复杂的数据,例如映射。我们可以使用 keccak256()
方法创建一个哈希键值,以便在 balances
映射中为 balanceHolder
设置余额:
_storage.setUint(keccak256("balances", balanceHolder), amount);
这些低级存储函数比经常使用的 "balances[address] = amount"
;语法更冗长复杂,因此将它们封装在一些更高级的函数中更有意义。下面来看看 TokenDelegate 中是如何实现的:
contract TokenDelegate is StorageStateful {
using SafeMath for uint256;
function transfer(address to, uint256 value) public returns (bool) {
require(to != address(0));
require(value <= getBalance(msg.sender));
subBalance(msg.sender, value);
addBalance(to, value);
return true;
}
function balanceOf(address owner) public view returns (uint256 balance) {
return getBalance(owner);
}
function getBalance(address balanceHolder) public view returns (uint256) {
return _storage.getUint(keccak256("balances", balanceHolder));
}
function totalSupply() public view returns (uint256) {
return _storage.getUint("totalSupply");
}
function addSupply(uint256 amount) internal {
_storage.setUint("totalSupply", totalSupply().add(amount));
}
function addBalance(address balanceHolder, uint256 amount) internal {
setBalance(balanceHolder, getBalance(balanceHolder).add(amount));
}
function subBalance(address balanceHolder, uint256 amount) internal {
setBalance(balanceHolder, getBalance(balanceHolder).sub(amount));
}
function setBalance(address balanceHolder, uint256 amount) internal {
_storage.setUint(keccak256("balances", balanceHolder), amount);
}
}
类似于 getBalance()
的内部函数,能使余额存储变得更容易。该功能可以进一步重构到代码库中,以便在多个委托合约间共享。
假设我们使用一个指向 TokenDelegate
的代理指针部署 ShrimpCoin
(我们称之为V1)。由于 TokenDelegate
不能提供初始化创建机制或“铸币”的代币,V1的实现是受限的。
ShrimpCoin
的所有者地址可以调用 upgradeTo()
函数使得代理指针指向 MintableTokenDelegate
实例(我们称之为V2)。
V2 MintableTokenDelegate
合约提供了一些铸币的额外功能,可以操作一组全新的存储键值:
contract MintableTokenDelegate is TokenDelegate {
modifier onlyOwner {
require(msg.sender == _storage.getAddress("owner"));
_;
}
modifier canMint() {
require(!_storage.getBool("mintingFinished"));
_;
}
function mint(address to, uint256 amount) onlyOwner canMint public returns (bool) {
addSupply(amount);
addBalance(to, amount);
return true;
}
function finishMinting() onlyOwner canMint public returns (bool) {
_storage.setBool("mintingFinished", true);
return true;
}
}
它还继承了V1 TokenDelegate
的所有功能,因此像 ShrimpCoin
这样正代理的合约不会失去任何原始功能。
我们已经推出一个代币升级的简单示例,但该方式也可以应用到更复杂的情景中。我们在 Level K 上发布的一个令人兴奋的用例是可升级 代币策划注册表。
这些模式提供了一些非常酷的机会,可以为通用功能开发和部署可重用的委托合约,可以通过一个潜在的大规模多样化去中心化应用程序组来加以利用。
使用组合代理委托及键值存储的升级模式的优点有:
缺点:
我们很期待听到其他开发者使用这些模式。 Rocket Pool 项目正在用可升级性做一些非常Amazing的事情。我们很期待听到其他人的声音!
如果您发现此篇文章有帮助,请告诉我们!到 [email protected] 来表达你们的爱吧!
感谢您的阅读 :-)
参考文献: