Solidity的库驱动开发并重构ERC20 StandardToken最佳实践(二十八)|入门系列

2017/6/17 posted in  Solidity入门系列

这是一篇关于如何在EVM上通过使用库来开发更模块化,可重复、优雅的智能合约的实践文章1

Solidity是一个不够智能的语言

相对于Swift和Javascript,使用Solidity开发,在语言层面的语法支持,语法本身的表达能力相对弱很多。

Soldity,一个编译成字节码,在EVM虚拟机中执行语言。语言表达能力受限的原因如下:

首先,当执行时,你的代码会在网络中的每个节点中执行。一旦一个新节点,接收到一个新的区块,它将对其进行合法性校验。这将意味着执行相应的计算,检查合约的新状态是否正确。也许正因为EVM是图灵完备的语言,每个结点都要执行检查,这样沉重的计算任务仍然是昂贵的(关于计算量,已通过gas进行了直接的限制),最终将减慢整个网络的运行速度。

当前语言并不支持标准的库。数组和字符串当前十分不便,我已经自行实现了ASCII的编解码,以及小写转换算法 ,这在其它语言中,我都几乎不需要考虑实现。

其次,除了从一个区块链的transaction中拿到信息外(Oracle),你不能从外部世界中输入数据(EVM之外,因为共识问题)。另外一旦一个合约部署后,并不能直接更新(虽然你可以通过迁移和纯存储合约来绕过这个问题)。

为了保证以太坊计算平台的可行,某些限制是必须的(比如,你将永远不会在以太坊区块链上存储一张Google图片,并实现一个图像识别。因为其高复杂性,gas消耗。这本身不需要多节点的共识)。其它一些限制,仅仅是因为它还是一门相当早期的技术,虽然正在迅猛的发展中。

这也就是说,在以太坊之中,我们能创建一些有趣的项目。我最近发现了一些使用Solidity的Library特性,来让代码整洁,更发的组织代码。

什么是Library

在Solidity中,与合约有些不同,Library不能处理ether。你可以把它当作一个EVM中的单例,又或者是一个部署一次后不再部署,然后能被做任意其它合约调用的公共代码。

这带来的一个显然好处是节省大量的gas(当然也可以减少重复代码对区块链带来的污染),因为代码不用一而再,再而三的部署,不同的合约可以依赖于同一个已部署的合约。

事实上,多个合约依赖于一个现存的代码,可以让营造一个更安全的环境。因为一方面这些方面有良好的代码审查(比如,Zepelin所做的伟大的工作),以及这些代码经过线上实际运行的验证。比如在这个案例中,一个打算最大融资五千万的项目,ERC20代币的问题被查出来了。

声明:下面的代码编写时使用Solidityv0.4.8。下面的某些论断可能仅在那个版本有效,可能很快因为版本更新失效。

好了,进入正题,到底什么是Library。

库是一个特殊的合约,不允许payable的函数,不允许fallback函数(这些限制是在编译期间强制执行的,由此我们不能使用库来操作ether)。库通过关键字library定义,如library C{},与合约定义类似contract A{}

调用库函数时,将使用一个特殊的指令DELEGATECALL,这会将调用时的上下文信息传入到library中,就好像代码在合约自身中执行一样。我非常赞同Soldity文档中所说的,“库可以被看作是使用它的合约的一个隐式的父类”。

library C {
    function a() returns (address) {
        return address(this);
    }
}

contract A {
    function a() constant returns (address) {
        return C.a();
    }
}

库的linked

与显式的继承contract A is B{}不同的是,合约与之依赖的库是如何关联起来不是很清楚。如前面合约A在a()方法中调用库C,那引用库C使用的是什么地址呢,C又是如何与A的字节码产生关系的呢。

库的关联是发生在字节码层级。当合约A编译后,它会对需要库地址的地方保留一个类似0073__C_____________________________________630dbe671f这样的占位符,注意0dbe671fa()的签名。如果我们就这样部署A合约,将会失败,因为字节码并不合法。

库连接实际上则非常简单,即是替换所有库占位符为部署后的区块链上的库地址。一旦合约已经关联好了对应的库,那么它也可以正式部署了。

库的升级

早前(2017年2月):这是不可行的,同样的,合约也是不能更新的。如上一节所述,对库的引用是在字节码级而不是在存储级。 一旦部署,就不允许更改合约的字节码,因此,合约中的library随合约的存在永远存在。

更新(2017年3月):自这篇关于库升级的文章发表以来,我们在过去几个星期一直致力于库的更新问题的解决方案。我们也与Zeppelin的伙伴一起合作,发表了文章:https://medium.com/zeppelin-blog/proxy-libraries-in-solidity-79fbe4b970fd

不再使用旧的在合约中链接库地址,使用新的方法,链接到一个分发器,分发器将允许升级底层的库,以及合约的商业逻辑。

using结构体和方法

尽管库并没有storage,他们可以使用关联合约的storage。当传递一个库调用,库所进行的修改,将会保存在合约中的storage中。这有点类似于向函数中传递了C语言一样的指针,只有通过这种方式,库才可能是一个已经被部署过的,或已经存在于区块链上了。

使用using提供的语法糖,可以让这一切实现得简洁和好懂。我们来看一个下面的例子,这是基础,也可参考: http://me.tryblockchain.org/blockchain-solidity-Libraries.html

library CounterLib {
    struct Counter { uint i; }

    function incremented(Counter storage self) returns (uint) {
        return ++self.i;
    }
}

contract CounterContract {
    using CounterLib for CounterLib.Counter;

    CounterLib.Counter counter;

    function increment() returns (uint) {
        return counter.incremented();
    }
}

using关键字,在CounterLib数据结构Counter上附着了CounterLib库中定义的方法。CounterLib.Counter的实例在使用时,就好像它自己有了incremented(),调用方法时,会直接把这个实例作为第一个参数传入了函数。

这个结构体的语法非常类似于Go语言中的结构体上执行方法,虽然不是完整意义上的对象。

事件和库

库中不止没有storage,也没有event。但他们类似storage这样,转发事件,下面我来解释一下:

如之前所述,一个库可以被认为是被调用合约的隐式的基类。如果在基类合约中触发一个事件,它也会出现在主合约中事件日志中,同样的,库函数也是如此,当合约调用的库函数中的事件触发函数时,日志事件也会出现在合约的日志中。

当前的问题是,合约的ABI定义不能反映库中可能会触发的事件。这将导致客户端如web3,不知道如何解析事件,以及不知道如何解析参数。

这里有一个缓解的办法,是在合约和库中都定义同样的事件,这将让客户端认为合约触发对应的事件(而实际是库函数触发的)。

下面是一个简单的例子来说明这一切,尽管Emit事件由库触发,通过监听EventEmitterContract.Emit,我们可以监听事件。而相对来说,监听EventEmitterLib.Emit,反而不会得到什么事件。

library EventEmitterLib {
    function emit(string s) {
        Emit(s);
    }
    
    event Emit(string s);
}

contract EventEmitterContract {
    using EventEmitterLib for string;
    
    function emit(string s) {
        s.emit();
    }
    
    event Emit(string s);
}

实现一个ERC20库

作为一个现实世界使用库函数的例子。我将使用库函数重构Zeppelin的ERC20标准代币

第一步,我们将重写SaftMath为一个库。但因为库不能被继承,所以当前作为一个基类的方式需要调整。同样的,重构也会将让SaftMath使用起来更加简洁( safemUL(2, 3)将被调整为2.times(3) )。

library SafeMathLib {
  function times(uint a, uint b) returns (uint) {
    uint c = a * b;
    assert(a == 0 || c / a == b);
    return c;
  }

  function minus(uint a, uint b) returns (uint) {
    assert(b <= a);
    return a - b;
  }

  function plus(uint a, uint b) returns (uint) {
    uint c = a + b;
    assert(c>=a && c>=b);
    return c;
  }

  function assert(bool assertion) private {
    if (!assertion) throw;
  }
}

尽管库不能直接继承,但它们可以像合约关联库这样关联其它的合约,不过仍有库的那些基本的限制。

现在来看看真正干活的,ERC20Lib是一个包含所有业务逻辑的操作ERC20代币的库。它包含了TokenStorage结构体,用于代币相关的storage,下面是它的整个实现:

import "../SafeMathLib.sol";

library ERC20Lib {
  using SafeMathLib for uint;

  struct TokenStorage {
    mapping (address => uint) balances;
    mapping (address => mapping (address => uint)) allowed;
    uint totalSupply;
  }

  event Transfer(address indexed from, address indexed to, uint value);
  event Approval(address indexed owner, address indexed spender, uint value);
  
  function init(TokenStorage storage self, uint _initial_supply) {
    self.totalSupply = _initial_supply;
    self.balances[msg.sender] = _initial_supply;
  }
  
  function transfer(TokenStorage storage self, address _to, uint _value) returns (bool success) {
    self.balances[msg.sender] = self.balances[msg.sender].minus(_value);
    self.balances[_to] = self.balances[_to].plus(_value);
    Transfer(msg.sender, _to, _value);
    return true;
  }

  function transferFrom(TokenStorage storage self, address _from, address _to, uint _value) returns (bool success) {
    var _allowance = self.allowed[_from][msg.sender];

    self.balances[_to] = self.balances[_to].plus(_value);
    self.balances[_from] = self.balances[_from].minus(_value);
    self.allowed[_from][msg.sender] = _allowance.minus(_value);
    Transfer(_from, _to, _value);
    return true;
  }

  function balanceOf(TokenStorage storage self, address _owner) constant returns (uint balance) {
    return self.balances[_owner];
  }

  function approve(TokenStorage storage self, address _spender, uint _value) returns (bool success) {
    self.allowed[msg.sender][_spender] = _value;
    Approval(msg.sender, _spender, _value);
    return true;
  }

  function allowance(TokenStorage storage self, address _owner, address _spender) constant returns (uint remaining) {
    return self.allowed[_owner][_spender];
  }
}

现在所有需要业务逻辑都已经封装进了库中。再实现StandardToken将变得非常简单,实现一些代币自身的逻辑代码,以及一些直接使用TokenStorage调用库函数的入口函数(当然也包括上面说的事件)。

import './ERC20Lib.sol';

contract StandardToken {
   using ERC20Lib for ERC20Lib.TokenStorage;

   ERC20Lib.TokenStorage token;

   string public name = "SimpleToken";
   string public symbol = "SIM";
   uint public decimals = 18;
   uint public INITIAL_SUPPLY = 10000;

   function StandardToken() {
      token.init(INITIAL_SUPPLY);
   }

   function totalSupply() constant returns (uint) {
     return token.totalSupply;
   }

   function balanceOf(address who) constant returns (uint) {
     return token.balanceOf(who);
   }

   function allowance(address owner, address spender) constant returns (uint) {
     return token.allowance(owner, spender);
   }

   function transfer(address to, uint value) returns (bool ok) {
     return token.transfer(to, value);
   }

   function transferFrom(address from, address to, uint value) returns (bool ok) {
     return token.transferFrom(from, to, value);
   }

   function approve(address spender, uint value) returns (bool ok) {
     return token.approve(spender, value);
   }

   event Transfer(address indexed from, address indexed to, uint value);
   event Approval(address indexed owner, address indexed spender, uint value);
 }

上述实现最有趣的一点是,ERC20LibSafeMathLib都仅需部署一次,然后所有的合约都可以关联ERC20Lib来使用同样的,安全的,被审查过的代码。

完整的重构在Aragon的Zeppelin fork,所有关于标准代币的测试用例都可以通过,尽管整个底层的实现架构根本上被更改了。

封装

正如之前我们提到的那样,Solidity在提高程序员的生产力,以及语言自身的表达能力,还有很长的路要走。而我认为,库函数特性提供了一种很好的重用代码的手段。

对于我们在aragon来说,使用库函数开发非常重要,因为如果我们多次部署相同的代码,我们尽量减少修改的范围,或不作修改。使用这样的架构将能减少客户的交易费用,同样也能提供一个证明,你能使用另一个成功组织当前正在使用的代码。

关于作者

专注基于以太坊(Ethereum)的相关区块链(Blockchain)技术,了解以太坊,Solidity,Truffle,web3.js。

个人博客: http://tryblockchain.org
版权所有,转载注明出处

参考资料

友情链接: 区块链技术中文社区