这是一篇关于如何在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
这样的占位符,注意0dbe671f
是a()
的签名。如果我们就这样部署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);
}
上述实现最有趣的一点是,ERC20Lib
和SafeMathLib
都仅需部署一次,然后所有的合约都可以关联ERC20Lib
来使用同样的,安全的,被审查过的代码。
完整的重构在Aragon的Zeppelin fork,所有关于标准代币的测试用例都可以通过,尽管整个底层的实现架构根本上被更改了。
封装
正如之前我们提到的那样,Solidity在提高程序员的生产力,以及语言自身的表达能力,还有很长的路要走。而我认为,库函数特性提供了一种很好的重用代码的手段。
对于我们在aragon来说,使用库函数开发非常重要,因为如果我们多次部署相同的代码,我们尽量减少修改的范围,或不作修改。使用这样的架构将能减少客户的交易费用,同样也能提供一个证明,你能使用另一个成功组织当前正在使用的代码。
关于作者
专注基于以太坊(Ethereum)的相关区块链(Blockchain)技术,了解以太坊,Solidity,Truffle,web3.js。
个人博客: http://tryblockchain.org
版权所有,转载注明出处
参考资料
处于某些特定的环境下,可以看到评论框,欢迎留言交流^_^。