智能合约灵活升级

以太坊智能合约具有很强的不变性,使得我们能够构建完全防篡改的应用程序,任何个人、公司或政府都不能篡改数据(信息)。每个参与者都遵循相同的规则,并且这些规则永远都不会改变。

但是,说到底,这些规则都是由人创造的。而人类总是偶然会犯一点错误的。我们不可能从第一天就看到未来发展的完整画面,并构造一个完全不需要适配或改进的完美系统。

为了平衡不变性与灵活性,我们需要一种升级部署后的去中心化应用程序的机制。在本文中,我们将介绍如何使用一些简单但有效的模式来实现这一点。

虽然我们将描述升级机制,但我们不会讨论升级是如何触发的。我们假设升级操作将由“所有者”执行。该“所有者”可以是一个单独持有的地址、一个多签名合约,或者一个复杂的去中心化自治组织(DAO)。

现有模式

Zeppelin Solutions和Aragon团队已经提出了一些非常有效的升级模式。我们借鉴了 Solidity 代理库(Proxy Libraries in Solidity) (中译本见文末超链接)以及 使用永久存储升级智能合约(Smart Contract Upgradability Using Eternal Storage) 的代码。

Dapp 可升级工具箱

在 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) }
    }
  }

}

-来自 https://blog.zeppelinos.org/smart-contract-upgradeability-using-eternal-storage/ -

我们来看 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 变量),但变量名(numn)并不一致。即使这些变量名不相同,但它们仍可以通过匹配存储内存编译成字节码。因此,当 Thing 代理调用 ThingDelegateincrementNum() 方法时,也会在 Thing 的状态中增加 num 变量。

此外,额外存储和状态的定义在这( string name = "Thing" ,字符串类型变量name,内容为"Thing")。该存储空间不能被ThingDelegate修改。存储的顺序在这里非常重要。如果变量name定义在变量num之前,那么incrementNum()将会试图给一个字符串加一。

我们很喜欢这个模式的地方是, ThingDelegate 不需要知道 Thing。一旦 ThingDelegate 部署完成,任何合约都可以将其作为委托使用,因此 ThingDelegate 是可以公开使用的。实际上,任意已部署的合约都可以作为委托使用,并且不需要这样定义。

ShrimpCoin 与 TokenDelegate

让我们来看一看稍微复杂一点的 ShrimpCoinTokenDelegate 功能,以及一些存储辅助(类), 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 结构。用于存储 uint256bool 以及 address 类型的数据。这些映射用最高级的键值是 msg.sender ,(msg.sender)是使用 set/get 函数执行写或读操作的智能合约的地址。

逻辑上,键/值存储结构如下:

_uintStorage
   <shrimp_coin_address>
      "totalSupply": 1000
   <clam_token_address>
      "totalSupply": 2000
_boolStorage
   <shrimp_coin_address>
      "isPaused": true
   <clam_token_address>
      "isPaused": false

在我们的例子中, msg.senderShrimpCoin 合约地址,而键值可能形如 "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() 的内部函数,能使余额存储变得更容易。该功能可以进一步重构到代码库中,以便在多个委托合约间共享。

升级到 Mintable 代币

假设我们使用一个指向 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 上发布的一个令人兴奋的用例是可升级 代币策划注册表。

这些模式提供了一些非常酷的机会,可以为通用功能开发和部署可重用的委托合约,可以通过一个潜在的大规模多样化去中心化应用程序组来加以利用。

使用组合代理委托及键值存储的升级模式的优点有:

  • 对于功能和存储升级提供全灵活性
  • 鼓励封装通用功能的标准合约的创建与部署
  • 使用预先部署的智能合约当做委托不容易出错(将在将来进行全面测试)。
  • 重复利用预先部署的智能合约意味着更简单的审计,并减少部署所需gas成本。

缺点:

  • 升级“所有者”拥有完全控制权,意味着完全信任。为设计一个真正去信任的(Trustless)、也可升级的合约,“所有者”自身必须是一个去信任的合约。
  • 简直存储操作的语法与标准 Solidity 状态变量操作更复杂。
  • 标准共享合约中的一个缺陷可能波及到所有使用该合约的去中心化应用程序。

我们很期待听到其他开发者使用这些模式。 Rocket Pool 项目正在用可升级性做一些非常Amazing的事情。我们很期待听到其他人的声音!

如果您发现此篇文章有帮助,请告诉我们!到 contact@levelk.io 来表达你们的爱吧!

感谢您的阅读 🙂

参考文献:

  • 全ShrimpCoin例子
  • Level K 去中心化应用程序升级工具箱(正在开发中)
  • Zeppelin Solutions and Aragon: Solidity 代理代码库(编者注:中译本见文末)
  • 使用永久存储升级智能合约
  • Rocket Pool:可升级 Solidity 合约设计
  • Colony:使用Solidity编写可升级合约

原文链接: https://medium.com/level-k/flexible-upgradability-for-smart-contracts-9778d80d1638
作者: Mike Calvanese
翻译&校对: 贾林鹏 & Elisa

本文由作者授权 EthFans 翻译及再出版。

你可能还会喜欢:

干货 | 以太坊可更新智能合约研究与开发综述
干货 | Solidity 中的代理库
科普 | 理解ERC-20 token合约

评论