作为一门面向区块链平台设计的图灵完备的编程语言,Solidity支持函数调用、修饰符、重载、事件、继承等多种特性,在区块链社区中,拥有广泛的影响力和踊跃的社区支持。但对于刚接触区块链的人而言,Solidity是一门陌生的语言。
智能合约编写阶段将从Solidity基础特性、高级特性、设计模式以及编程攻略分别展开,带读者认识Solidity并掌握其运用,更好地进行智能合约开发。
智能合约代码结构
任何编程语言都有其规范的代码结构,用于表达在一个代码文件中如何组织和编写代码,Solidity也一样。
本节,我们将通过一个简单的合约示例,来了解智能合约的代码结构。
上面这段程序包括了以下功能:
通过构造函数来部署合约
通过setValue函数设置合约状态
通过getValue函数查询合约状态
整个合约主要分为以下几个构成部分:
状态变量 - _admin, _state,这些变量会被永久保存,也可以被函数修改
构造函数 - 用于部署并初始化合约
事件 - SetState, 功能类似日志,记录了一个事件的发生
修饰符 - onlyAdmin, 用于给函数加一层"外衣"
函数 - setState, getState,用于读写状态变量
下面将逐一介绍上述构成部分。
状态变量
状态变量是合约的骨髓,它记录了合约的业务信息。用户可以通过函数来修改这些状态变量,这些修改也会被包含到交易中;交易经过区块链网络确认后,修改即为生效。
uint private _state;
状态变量的声明方式为:[类型] [访问修饰符-可选] [字段名]
构造函数
构造函数用于初始化合约,它允许用户传入一些基本的数据,写入到状态变量中。
在上述例子中,设置了_admin字段,作为后面演示其他功能的前提。
和java不同的是,构造函数不支持重载,只能指定一个构造函数。
函数
函数被用来读写状态变量。对变量的修改将会被包含在交易中,经区块链网络确认后才生效。生效后,修改会被永久的保存在区块链账本中。
函数签名定义了函数名、输入输出参数、访问修饰符、自定义修饰符。
function setState(uint value) public onlyAdmin;
函数还可以返回多个返回值:
在本合约中,还有一个配备了view修饰符的函数。这个view表示了该函数不会修改任何状态变量。
与view类似的还有修饰符pure,其表明该函数是纯函数,连状态变量都不用读,函数的运行仅仅依赖于参数。
如果在view函数中尝试修改状态变量,或者在pure函数中访问状态变量,编译器均会报错。
事件
事件类似于日志,会被记录到区块链中,客户端可以通过web3订阅这些事件。
定义事件
event SetState(uint value);
构造事件
emit SetState(value);
这里有几点需要注意:
事件的名称可以任意指定,不一定要和函数名挂钩,但推荐两者挂钩,以便清晰地表达发生的事情.
构造事件时,也可不写emit,但因为事件和函数无论是名称还是参数都高度相关,这样操作很容易笔误将事件写成函数调用,因此不推荐。
Solidity编程风格应采用一定的规范。关于编程风格,建议参考
https://learnblockchain.cn/docs/solidity/style-guide.html#id16
修饰符
修饰符是合约中非常重要的一环。它挂在函数声明上,为函数提供一些额外的功能,例如检查、清理等工作。
在本例中,修饰符onlyAdmin要求函数调用前,需要先检测函数的调用者是否为函数部署时设定的那个管理员(即合约的部署人)。
值得注意的是,定义在修饰符中的下划线“_”,表示函数的调用,指代的是开发者用修饰符修饰的函数。在本例中,表达的是setState函数调用的意思。
智能合约的运行
了解了上述的智能合约示例的结构,就可以直接上手运行,运行合约的方式有多种,大家可以任意采取其中一种:
方法一:可以使用FISCO BCOS控制台的方式来部署合约,具体请参考
https://fisco-bcos-documentation.readthedocs.io/zh_CN/latest/docs/installation.html#id7
方法二:使用FISCO BCOS开源项目WeBASE提供的在线ide WEBASE-front运行
方法三:通过在线ide remix来进行合约的部署与运行, remix的地址为
http://remix.ethereum.org/
本例中使用remix作为运行示例。
编译
首先,在remix的文件ide中键入代码后,通过编译按钮来编译。成功后会在按钮上出现一个绿色对勾:
部署
编译成功后就可进行部署环节,部署成功后会出现合约实例。
setState
合约部署后,我们来调用setState(4)。在执行成功后,会产生一条交易收据,里面包含了交易的执行信息。
在这里,用户可以看到交易执行状态(status)、交易执行人(from)、交易输入输出(decoded input, decoded output)、交易开销(execution cost)以及交易日志(logs)。
在logs中,我们看到SetState事件被抛出,里面的参数也记录了事件传入的值4。
如果我们换一个账户来执行,那么调用会失败,因为onlyAdmin修饰符会阻止用户调用。
getState
调用getState后,可以直接看到所得到的值为4,正好是我们先前setState所传入的值:
Solidity数据类型
在前文的示例中,我们用到了uint等数据类型。由于Solidity类型设计比较特殊,这里也会简单介绍一下Solidity的数据类型。
整型系列
Solidity提供了一组数据类型来表示整数, 包含无符号整数与有符号整数。每类整数还可根据长度细分,具体细分类型如下。
定长bytes系列
Solidity提供了bytes1到bytes32的类型,它们是固定长度的字节数组。
用户可以读取定长bytes的内容。
并且,可以将整数类型转换为bytes。
这里有一个关键细节,Solidity采取大端序编码,高地址存的是整数的小端。例如,b[0]是低地址端,它存整数的高端,所以值为0;取b[31]才是1。
变长bytes
从上文中,读者可了解定长byte数组。此外,Solidity还提供了一个变长byte数组:bytes。使用方式类似数组,后文会有介绍。
string
Solidity提供的string,本质是一串经UTF-8编码的字节数组,它兼容于变长bytes类型。
目前Solidity对string的支持不佳,也没有字符的概念。用户可以将string转成bytes。
要注意的是,当将string转换成bytes时,数据内容本身不会被拷贝,如上文中,str和b变量指向的都是同一个字符串abc。
address
address表示账户地址,它由私钥间接生成,是一个20字节的数据。同样,它也可以被转换为bytes20。
mapping
mapping表示映射, 是极其重要的数据结构。它与java中的映射存在如下几点差别:
它无法迭代keys,因为它只保存键的哈希,而不保存键值,如果想迭代,可以用开源的可迭代哈希类库
如果一个key未被保存在mapping中,一样可以正常读取到对应value,只是value是空值(字节全为0)。所以它也不需要put、get等操作,用户直接去操作它即可。
数组
如果数组是状态变量,那么支持push等操作:
数组也可以以局部变量的方式使用,但稍有不同:
struct
Solidity允许开发者自定义结构对象。结构体既可以作为状态变量存储,也可以在函数中作为局部变量存在。
本节中只介绍了比较常见的数据类型,更完整的列表可参考Solidity官方网站:
https://solidity.readthedocs.io/en/v0.6.3/types.html
全局变量
示例合约代码的构造函数中,包含msg.sender。它属于全局变量。在智能合约中,全局变量或全局方法可用于获取和当前区块、交易相关的一些基本信息,如块高、块时间、合约调用者等。
比较常用的全局变量是msg变量,表示调用上下文,常见的全局变量有以下几种:
msg.sender:合约的直接调用者。
由于是直接调用者,所以当处于 用户A->合约1->合约2 调用链下,若在合约2内使用msg.sender,得到的会是合约1的地址。如果想获取用户A,可以用tx.origin.
tx.origin:交易的"始作俑者",整个调用链的起点。
msg.calldata:包含完整的调用信息,包括函数标识、参数等。calldata的前4字节就是函数标识,与msg.sig相同。
msg.sig:msg.calldata的前4字节,用于标识函数。
block.number:表示当前所在的区块高度。
now:表示当前的时间戳。也可以用block.timestamp表示。
这里只列出了部分常见全局变量,完整版本请参考:
https://solidity.readthedocs.io/en/v0.4.24/units-and-global-variables.html。
合理控制函数和变量的类型
基于最少知道原则(Least Knowledge Principle)中经典面向对象编程原则,一个对象应该对其他对象保持最少的了解。优秀的Solidity编程实践也应符合这一原则:每个合约都清晰、合理地定义函数的可见性,暴露最少的信息给外部,做好对内部函数可见性的管理。
同时,正确地修饰函数和变量的类型,可给合约内部数据提供不同级别的保护,以防止程序中非预期的操作导致数据产生错误;还能提升代码的可读性与质量,减少误解和bug;更有利于优化合约执行的成本,提升链上资源的使用效率。
守住函数操作的大门:函数可见性
Solidity有两种函数调用方式:
内部调用:又被称为『消息调用』。常见的有合约内部函数、父合约的函数以及库函数的调用。(例如,假设A合约中存在f函数,则在A合约内部,其他函数调用f函数的调用方式为f()。)
外部调用:又被称为『EVM调用』。一般为跨合约的函数调用。在同一合约内部,也可以产生外部调用。(例如,假设A合约中存在f函数,则在B合约内可通过使用A.f()调用。在A合约内部,可以用this.f()来调用。)。
函数可以被指定为 external ,public ,internal 或者 private标识符来修饰。
基于以上表格,我们可以得出函数的可见性 public > external > internal > private。
另外,如果函数不使用上述类型标识符,那么默认情况下函数类型为 public。
综上所述,我们可以总结一下以上标识符的不同使用场景:
public,公有函数,系统默认。通常用于修饰可对外暴露的函数,且该函数可能同时被内部调用。
external,外部函数,推荐只向外部暴露的函数使用。当函数的某个参数非常大时,如果显式地将函数标记为external,可以强制将函数存储的位置设置为calldata,这会节约函数执行时所需存储或计算资源。
internal,内部函数,推荐所有合约内不对合约外暴露的函数使用,可以避免因权限暴露被攻击的风险。
private,私有函数,在极少数严格保护合约函数不对合约外部开放且不可被继承的场景下使用。
不过,需要注意的是,无论用何种标识符,即使是private,整个函数执行的过程和数据是对所有节点可见,其他节点可以验证和重放任意的历史函数。实际上,整个智能合约所有的数据对区块链的参与节点来说都是透明的。
刚接触区块链的用户常会误解,在区块链上可以通过权限控制操作来控制和保护上链数据的隐私。
这是一种错误的观点。事实上,在区块链业务数据未做特殊加密的前提下,区块链同一账本内的所有数据经过共识后落盘到所有节点上,链上数据是全局公开且相同的,智能合约只能控制和保护合约数据的执行权限。
如何正确地选择函数修饰符是合约编程实践中的『必修课』,只有掌握此节真谛方可自如地控制合约函数访问权限,提升合约安全性。
对外暴露最少的必要信息:变量的可见性
与函数一样,对于状态变量,也需要注意可见性修饰符。状态变量的修饰符默认是internal,不能设置为external。此外,当状态变量被修饰为public,编译器会生成一个与该状态变量同名的函数。
具体可参考以下示例:
pragma solidity ^0.4.0;
contract TestContract {
uint public year = 2020;
}
contract Caller {
TestContract c = new TestContract();
function f() public {
uint local = c.year();
//expected to be 2020
}
}
这个机制有点像Java语言里lombok库所提供的@Getter注解,默认为一个POJO类变量生成get函数,大大简化了某些合约代码的书写。
同样,变量的可见性也需要被合理地修饰,不该公开的变量果断用private修饰,使合约代码更符合『最少知道』的设计原则。
精确地将函数分类:函数的类型
函数可以被声明为pure、view,两者的作用可见下图。
那么,什么是读取或修改状态呢?简单来说,两个状态就是读取或修改了账本相关的数据。
在FISCO BCOS中,读取状态可能是:
读取状态变量。
访问 block,tx, msg 中任意成员 (除 msg.sig 和 msg.data 之外)。
调用任何未标记为 pure 的函数。
使用包含某些操作码的内联汇编。
而修改状态可能是:
修改状态变量。
产生事件。
创建其它合约。
使用 selfdestruct。
调用任何没有标记为 view 或者 pure 的函数。
使用底层调用。
使用包含特定操作码的内联汇编。
需要注意的是,在某些版本编译器中,并没有对这两个关键字进行强制的语法检查。
推荐尽可能使用pure和view来声明函数,例如将没有读取或修改任何状态的库函数声明为pure,这样既提升了代码可读性,也使其更赏心悦目,何乐而不为?
编译时就确定的值:状态常量
所谓的状态常量是指被声明为constant的状态变量。
一旦某个状态变量被声明为constant,那么该变量值只能为编译时确定的值,无法被修改。编译器一般会在编译状态计算出此变量实际值,不会给变量预留储存空间。所以,constant只支持修饰值类型和字符串。
状态常量一般用于定义含义明确的业务常量值。
面向切片编程:函数修饰器(modifier)
Solidity提供了强大的改变函数行为的语法:函数修饰器(modifier)。一旦某个函数加上了修饰器,修饰器内定义的代码就可以作为该函数的装饰被执行,类似其他高级语言中装饰器的概念。
这样说起来很抽象,让我们来看一个具体的例子:
pragma solidity ^0.4.11;
contract owned {
function owned() public { owner = msg.sender; }
address owner;
// 修饰器所修饰的函数体会被插入到特殊符号 _; 的位置。
modifier onlyOwner {
require(msg.sender == owner);
_;
}
// 使用onlyOwner修饰器所修饰,执行changeOwner函数前需要首先执行onlyOwner"_;"前的语句。
function changeOwner(address _owner) public onlyOwner {
owner = _owner;
}
}
如上所示,定义onlyOwner修饰器后,在修饰器内,require语句要求msg.sender必须等于owner。后面的"_;"表示所修饰函数中的代码。
所以,代码实际执行顺序变成了:
执行onlyOwner修饰器的语句,先执行require语句。(执行第9行)
执行changeOwner函数的语句。(执行第15行)
由于changeOwner函数加上了onlyOwner的修饰,故只有当msg.sender是owner才能成功调用此函数,否则会报错回滚。
同时,修饰器还能传入参数,例如上述的修饰器也可写成:
modifier onlyOwner(address sender) {
require(sender == owner);
_;
}
function changeOwner(address _owner) public onlyOwner(msg.sender) {
owner = _owner;
}
同一个函数可有多个修饰器,中间以空格间隔,修饰器依次检查执行。此外,修饰器还可以被继承和重写。
由于其所提供的强大功能,修饰器也常被用来实现权限控制、输入检查、日志记录等。
比如,我们可以定义一个跟踪函数执行的修饰器:
event LogStartMethod();
event LogEndMethod();
modifier logMethod {
emit LogStartMethod();
_;
emit LogEndMethod();
}
这样,任何用logMethod修饰器来修饰的函数都可记录其函数执行前后的日志,实现日志环绕效果。如果你已经习惯了使用Spring框架的AOP,也可以试试用modifier实现一个简单的AOP功能。
modifier最常见的打开方式是通过提供函数的校验器。在实践中,合约代码的一些检查语句常会被抽象并定义为一个modifier,如上述例子中的onlyOwner就是个最经典的权限校验器。这样一来,连检查的逻辑也能被快速复用,用户也不用再为智能合约里到处都是参数检查或其他校验类代码而苦恼。
可以debug的日志:合约里的事件(Event)
介绍完函数和变量,我们来聊聊Solidity其中一个较为独有的高级特性——事件机制。
事件允许我们方便地使用 EVM 的日志基础设施,而Solidity的事件有以下作用:
记录事件定义的参数,存储到区块链交易的日志中,提供廉价的存储。
提供一种回调机制,在事件执行成功后,由节点向注册监听的SDK发送回调通知,触发回调函数被执行。
提供一个过滤器,支持参数的检索和过滤。
事件的使用方法非常简单,两步即可玩转。
第一步,使用关键字『event』来定义一个事件。建议事件的命名以特定前缀开始或以特定后缀结束,这样更便于和函数区分,在本文中我们将统一以『Log』前缀来命名事件。下面,我们用『event』来定义一个函数调用跟踪的事件:
event LogCallTrace(address indexed from, address indexed to, bool result);
事件在合约中可被继承。当他们被调用时,会将参数存储到交易的日志中。这些日志被保存到区块链中,与地址相关联。在上述例子中,用indexed标记参数被搜索,否则,这些参数被存储到日志的数据中,无法被搜索。
第二步,在对应的函数内触发定义事件。调用事件的时候,在事件名前加上『emit』关键字:
function f() public {
emit LogCallTrace(msg.sender, this, true);
}
这样,当函数体被执行的时候,会触发执行LogCallTrace。
最后,在FISCO BCOS的Java SDK中,合约事件推送功能提供了合约事件的异步推送机制,客户端向节点发送注册请求,在请求中携带客户端关注的合约事件参数,节点根据请求参数对请求区块范围的Event Log进行过滤,将结果分次推送给客户端。更多细节可以参考合约事件推送功能文档。在SDK中,可以根据事件的indexed属性,根据特定值进行搜索。
合约事件推送功能文档:
https://fisco-bcos-documentation.readthedocs.io/zh_CN/latest/docs/sdk/java_sdk.html#id14
不过,日志和事件无法被直接访问,甚至在创建的合约中也无法被直接访问。
但好消息是日志的定义和声明非常利于在『事后』进行追溯和导出。
例如,我们可以在合约的编写中,定义和埋入足够的事件,通过WeBASE的数据导出子系统我们可以将所有日志导出到MySQL等数据库中。这特别适用于生成对账文件、生成报表、复杂业务的OLTP查询等场景。此外,WeBASE提供了一个专用的代码生成子系统帮助分析具体的业务合约,自动生成相应的代码。
WeBASE的数据导出子系统:
https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Collect-Bee/index.html
代码生成子系统:
https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Codegen-Monkey/index.html
在Solidity中,事件是一个非常有用的机制,如果说智能合约开发最大的难点是debug,那善用事件机制可以让你快速制伏Solidity开发。
面向对象之重载
重载是指合约具有多个不同参数的同名函数。对于调用者来说,可使用相同函数名来调用功能相同,但参数不同的多个函数。在某些场景下,这种操作可使代码更清晰、易于理解,相信有一定编程经验的读者对此一定深有体会。
下面将展示一个典型的重载语法:
pragma solidity ^0.4.25;
contract Test {
function f(uint _in) public pure returns (uint out) {
out = 1;
}
function f(uint _in, bytes32 _key) public pure returns (uint out) {
out = 2;
}
}
需要注意的是,每个合约只有一个构造函数,这也意味着合约的构造函数是不支持重载的。
我们可以想像一个没有重载的世界,程序员一定绞尽脑汁、想方设法给函数起名,大家的头发可能又要多掉几根。
面向对象之继承
Solidity使用『is』作为继承关键字。因此,以下这段代码表示的是,合约B继承了合约A:
pragma solidity ^0.4.25;
contract A {
}
contract B is A {
}
而继承的合约B可以访问被继承合约A的所有非private函数和状态变量。
在Solidity中,继承的底层实现原理为:当一个合约从多个合约继承时,在区块链上只有一个合约被创建,所有基类合约的代码被复制到创建的合约中。
相比于C++或Java等语言的继承机制,Solidity的继承机制有点类似于Python,支持多重继承机制。因此,Solidity中可以使用一个合约来继承多个合约。
在某些高级语言中,比如Java,出于安全性和可靠性的考虑,只支持单重继承,通过使用接口机制来实现多重继承。对于大多数场景而言,单继承的机制就可以满足需求了。
多继承会带来很多复杂的技术问题,例如所谓的『钻石继承』等,建议在实践中尽可能规避复杂的多继承。
继承简化了人们对抽象合约模型的认识和描述,清晰体现了相关合约间的层次结构关系,并且提供软件复用功能。这样,能避免代码和数据冗余,增加程序的重用性。
面向对象之抽象类和接口
根据依赖倒置原则,智能合约应该尽可能地面向接口编程,而不依赖具体实现细节。
Solidity支持抽象合约和接口的机制。
如果一个合约,存在未实现的方法,那么它就是抽象合约。例如:
pragma solidity ^0.4.25;
contract Vehicle {
//抽象方法
function brand() public returns (bytes32);
}
抽象合约无法被成功编译,但可以被继承。
接口使用关键字interface,上面的抽象也可以被定义为一个接口。
pragma solidity ^0.4.25;
interface Vehicle {
//抽象方法
function brand() public returns (bytes32);
}
接口类似于抽象合约,但不能实现任何函数,同时,还有进一步的限制:
无法继承其他合约或接口。
无法定义构造函数。
无法定义变量。
无法定义结构体
无法定义枚举。
在软件开发中,很多经典原则可以提升软件的质量,其中最为经典的就是尽可能复用久经考验、反复打磨、严格测试的高质量代码。此外,复用成熟的库代码还可以提升代码的可读性、可维护性,甚至是可扩展性。
和所有主流语言一样,Solidity也提供了库(Library)的机制。Solidity的库有以下基本特点:
用户可以像使用合约一样使用关键词library来创建合约。
库既不能继承也不能被继承。
库的internal函数对调用者都是可见的。
库是无状态的,无法定义状态变量,但是可以访问和修改调用合约所明确提供的状态变量。
接下来,我们来看一个简单的例子,以下是FISCO BCOS社区中一个LibSafeMath的代码库。我们对此进行了精简,只保留了加法的功能:
pragma solidity ^0.4.25;
library LibSafeMath {
/**
* @dev Adds two numbers, throws on overflow.
*/
function add(uint256 a, uint256 b) internal returns (uint256 c) {
c = a + b;
assert(c >= a);
return c;
}
}
我们只需在合约中import库的文件,然后使用L.f()的方式来调用函数,(例如LibSafeMath.add(a,b))。
接下来,我们编写调用这个库的测试合约,合约内容如下:
pragma solidity ^0.4.25;
import "./LibSafeMath.sol";
contract TestAdd {
function testAdd(uint256 a, uint256 b) external returns (uint256 c) {
c = LibSafeMath.add(a,b);
}
}
在FISCO BCOS控制台中,我们可以测试合约的结果(控制台的介绍文章详见FISCO BCOS 控制台详解,飞一般的区块链体验),运行结果如下:
=============================================================================================
Welcome to FISCO BCOS console(1.0.8)!
Type 'help' or 'h' for help. Type 'quit' or 'q' to quit console.
________ ______ ______ ______ ______ _______ ______ ______ ______
| | \/ \\ / \\ / \\ | \\ / \\ / \\ / \\
| $$$$$$$$\\$$$$$| $$$$$$| $$$$$$| $$$$$$\\ | $$$$$$$| $$$$$$| $$$$$$| $$$$$$\\
| $$__ | $$ | $$___\\$| $$ \\$| $$ | $$ | $$__/ $| $$ \\$| $$ | $| $$___\\$$
| $$ \\ | $$ \\$$ \\| $$ | $$ | $$ | $$ $| $$ | $$ | $$\\$$ \\
| $$$$$ | $$ _\\$$$$$$| $$ __| $$ | $$ | $$$$$$$| $$ __| $$ | $$_\\$$$$$$\\
| $$ _| $$_| \\__| $| $$__/ | $$__/ $$ | $$__/ $| $$__/ | $$__/ $| \\__| $$
| $$ | $$ \\\\$$ $$\\$$ $$\\$$ $$ | $$ $$\\$$ $$\\$$ $$\\$$ $$
\\$$ \\$$$$$$ \\$$$$$$ \\$$$$$$ \\$$$$$$ \\$$$$$$$ \\$$$$$$ \\$$$$$$ \\$$$$$$
=============================================================================================
[group:1]> deploy TestAdd
contract address: 0xe2af1fd7ecd91eb7e0b16b5c754515b775b25fd2
[group:1]> call TestAdd 0xe2af1fd7ecd91eb7e0b16b5c754515b775b25fd2 testAdd 2000 20
transaction hash: 0x136ce66603aa6e7fd9e4750fcf25302b13171abba8c6b2109e6dd28111777d54
---------------------------------------------------------------------------------------------
Output
function: testAdd(uint256,uint256)
return type: (uint256)
return value: (2020)
---------------------------------------------------------------------------------------------
[group:1]>
通过以上示例,我们可清晰了解在Solidity中应如何使用库。
类似Python,在某些场景下,指令『using A for B;』可用于附加库函数(从库 A)到任何类型(B)。这些函数将接收到调用它们的对象作为第一个参数(像 Python 的 self 变量)。这个功能使库的使用更加简单、直观。
例如,我们对代码进行如下简单修改:
pragma solidity ^0.4.25;
import "./LibSafeMath.sol";
contract TestAdd {
// 添加using ... for ... 语句,库 LibSafeMath 中的函数被附加在uint256的类型上
using LibSafeMath for uint256;
function testAdd(uint256 a, uint256 b) external returns (uint256 c) {
//c = LibSafeMath.add(a,b);
c = a.add(b);
//对象a直接被作为add方法的首个参数传入。
}
}
验证一下结果依然是正确的。
=============================================================================================
Welcome to FISCO BCOS console(1.0.8)!
Type 'help' or 'h' for help. Type 'quit' or 'q' to quit console.
________ ______ ______ ______ ______ _______ ______ ______ ______
| | \/ \\ / \\ / \\ | \\ / \\ / \\ / \\
| $$$$$$$$\\$$$$$| $$$$$$| $$$$$$| $$$$$$\\ | $$$$$$$| $$$$$$| $$$$$$| $$$$$$\\
| $$__ | $$ | $$___\\$| $$ \\$| $$ | $$ | $$__/ $| $$ \\$| $$ | $| $$___\\$$
| $$ \\ | $$ \\$$ \\| $$ | $$ | $$ | $$ $| $$ | $$ | $$\\$$ \\
| $$$$$ | $$ _\\$$$$$$| $$ __| $$ | $$ | $$$$$$$| $$ __| $$ | $$_\\$$$$$$\\
| $$ _| $$_| \\__| $| $$__/ | $$__/ $$ | $$__/ $| $$__/ | $$__/ $| \\__| $$
| $$ | $$ \\\\$$ $$\\$$ $$\\$$ $$ | $$ $$\\$$ $$\\$$ $$\\$$ $$
\\$$ \\$$$$$$ \\$$$$$$ \\$$$$$$ \\$$$$$$ \\$$$$$$$ \\$$$$$$ \\$$$$$$ \\$$$$$$
=============================================================================================
[group:1]> deploy TestAdd
contract address: 0xf82c19709a9057d8e32c19c23e891b29b708c01a
[group:1]> call TestAdd 0xf82c19709a9057d8e32c19c23e891b29b708c01a testAdd 2000 20
transaction hash: 0xcc44a80784404831d8522dde2a8855606924696957503491eb47174c9dbf5793
---------------------------------------------------------------------------------------------
Output
function: testAdd(uint256,uint256)
return type: (uint256)
return value: (2020)
---------------------------------------------------------------------------------------------
[group:1]>
更好地使用Solidity library有助于开发者更好地复用代码。除了Solidity社区提供的大量开源、高质量的代码库外,FISCO BCOS社区也计划推出全新的Solidity代码库,开放给社区用户,敬请期待。
当然,你也可以自己动手,编写可复用的代码库组件,并分享到社区。
智能合约设计模式概述
2019年,IEEE收录了维也纳大学一篇题为《Design Patterns For Smart Contracts In the Ethereum Ecosystem》的论文。这篇论文分析了那些火热的Solidity开源项目,结合以往的研究成果,整理出了18种设计模式。
这些设计模式涵盖了安全性、可维护性、生命周期管理、鉴权等多个方面。
接下来,本文将从这18种设计模式中选择最为通用常见的进行介绍,这些设计模式在实际开发经历中得到了大量检验。
安全性(Security)
智能合约编写,首要考虑的就是安全性问题。
在区块链世界中,恶意代码数不胜数。如果你的合约包含了跨合约调用,就要特别当心,要确认外部调用是否可信,尤其当其逻辑不为你所掌控的时候。
如果缺乏防人之心,那些“居心叵测”的外部代码就可能将你的合约破坏殆尽。比如,外部调用可通过恶意回调,使代码被反复执行,从而破坏合约状态,这种攻击手法就是著名的Reentrance Attack(重放攻击)。
这里,先引入一个重放攻击的小实验,以便让读者了解为什么外部调用可能导致合约被破坏,同时帮助更好地理解即将介绍的两种提升合约安全性的设计模式。
关于重放攻击,这里举个精简的例子。
AddService合约是一个简单的计数器,每个外部合约可以调用AddService合约的addByOne来将字段_count加一,同时通过require来强制要求每个外部合约最多只能调用一次该函数。
这样,_count字段就精确的反应出AddService被多少合约调用过。在addByOne函数的末尾,AddService会调用外部合约的回调函数notify。AddService的代码如下:
contract AddService{
uint private _count;
mapping(address=>bool) private _adders;
function addByOne() public {
//强制要求每个地址只能调用一次
require(_adders[msg.sender] == false, "You have added already");
//计数
_count++;
//调用账户的回调函数
AdderInterface adder = AdderInterface(msg.sender);
adder.notify();
//将地址加入已调用集合
_adders[msg.sender] = true;
}
}
contract AdderInterface{
function notify() public;
}
如果AddService如此部署,恶意攻击者可以轻易控制AddService中的_count数目,使该计数器完全失效。
攻击者只需要部署一个合约BadAdder,就可通过它来调用AddService,就可以达到攻击效果。BadAdder合约如下:
contract BadAdder is AdderInterface{
AddService private _addService = //...;
uint private _calls;
//回调
function notify() public{
if(_calls > 5){
return;
}
_calls++;
//Attention !!!!!!
_addService.addByOne();
}
function doAdd() public{
_addService.addByOne();
}
}
BadAdder在回调函数notify中,反过来继续调用AddService,由于AddService糟糕的代码设计,require条件检测语句被轻松绕过,攻击者可以直击_count字段,使其被任意地重复添加。
攻击过程的时序图如下:
在这个例子中,AddService难以获知调用者的回调逻辑,但依然轻信了这个外部调用,而攻击者利用了AddService糟糕的代码编排,导致悲剧的发生。
本例子中去除了实际的业务意义,攻击后果仅仅是_count值失真。真正的重放攻击,可对业务造成严重后果。比如在统计投票数目是,投票数会被改得面目全非。
打铁还需自身硬,如果想屏蔽这类攻击,合约需要遵循良好的编码模式,下面将介绍两个可有效解除此类攻击的设计模式。
Checks-Effects-Interaction - 保证状态完整,再做外部调用
该模式是编码风格约束,可有效避免重放攻击。通常情况下,一个函数可能包含三个部分:
Checks:参数验证
Effects:修改合约状态
Interaction:外部交互
这个模式要求合约按照Checks-Effects-Interaction的顺序来组织代码。它的好处在于进行外部调用之前,Checks-Effects已完成合约自身状态所有相关工作,使得状态完整、逻辑自洽,这样外部调用就无法利用不完整的状态进行攻击了。
回顾前文的AddService合约,并没有遵循这个规则,在自身状态没有更新完的情况下去调用了外部代码,外部代码自然可以横插一刀,让_adders[msg.sender]=true永久不被调用,从而使require语句失效。我们以checks-effects-interaction的角度审阅原来的代码:
//Checks
require(_adders[msg.sender] == false, "You have added already");
//Effects
_count++;
//Interaction
AdderInterface adder = AdderInterface(msg.sender);
adder.notify();
//Effects
_adders[msg.sender] = true;
只要稍微调整顺序,满足Checks-Effects-Interaction模式,悲剧就得以避免:
//Checks
require(_adders[msg.sender] == false, "You have added already");
//Effects
_count++;
_adders[msg.sender] = true;
//Interaction
AdderInterface adder = AdderInterface(msg.sender);
adder.notify();
由于_adders映射已经修改完毕,当恶意攻击者想递归地调用addByOne,require这道防线就会起到作用,将恶意调用拦截在外。
虽然该模式并非解决重放攻击的唯一方式,但依然推荐开发者遵循。
Mutex - 禁止递归
Mutex模式也是解决重放攻击的有效方式。它通过提供一个简单的修饰符来防止函数被递归调用:
contract Mutex {
bool locked;
modifier noReentrancy() {
//防止递归
require(!locked, "Reentrancy detected");
locked = true;
_;
locked = false;
}
//调用该函数将会抛出Reentrancy detected错误
function some() public noReentrancy{
some();
}
}
在这个例子中,调用some函数前会先运行noReentrancy修饰符,将locked变量赋值为true。如果此时又递归地调用了some,修饰符的逻辑会再次激活,由于此时的locked属性已为true,修饰符的第一行代码会抛出错误。
可维护性(Maintaince)
在区块链中,合约一旦部署,就无法更改。当合约出现了bug,通常要面对以下问题:
合约上已有的业务数据怎么处理?
怎么尽可能减少升级影响范围,让其余功能不受影响?
依赖它的其他合约该怎么办?
回顾面向对象编程,其核心思想是将变化的事物和不变的事物相分离,以阻隔变化在系统中的传播。所以,设计良好的代码通常都组织得高度模块化、高内聚低耦合。利用这个经典的思想可解决上面的问题。
Data segregation - 数据与逻辑相分离
了解该设计模式之前,先看看下面这个合约代码:
contract Computer{
uint private _data;
function setData(uint data) public {
_data = data;
}
function compute() public view returns(uint){
return _data * 10;
}
}
此合约包含两个能力,一个是存储数据(setData函数),另一个是运用数据进行计算(compute函数)。如果合约部署一段时间后,发现compute写错了,比如不应是乘以10,而要乘以20,就会引出前文如何升级合约的问题。
这时,可以部署一个新合约,并尝试将已有数据迁移到新的合约上,但这是一个很重的操作,一方面要编写迁移工具的代码,另一方面原先的数据完全作废,空占着宝贵的节点存储资源。
所以,预先在编程时进行模块化十分必要。如果我们将"数据"看成不变的事物,将"逻辑"看成可能改变的事物,就可以完美避开上述问题。Data Segregation(意为数据分离)模式很好地实现了这一想法。
该模式要求一个业务合约和一个数据合约:数据合约只管数据存取,这部分是稳定的;而业务合约则通过数据合约来完成逻辑操作。
结合前面的例子,我们将数据读写操作专门转移到一个合约DataRepository中:
contract DataRepository{
uint private _data;
function setData(uint data) public {
_data = data;
}
function getData() public view returns(uint){
return _data;
}
}
计算功能被单独放入一个业务合约中:
contract Computer{
DataRepository private _dataRepository;
constructor(address addr){
_dataRepository =DataRepository(addr);
}
//业务代码
function compute() public view returns(uint){
return _dataRepository.getData() * 10;
}
}
这样,只要数据合约是稳定的,业务合约的升级就很轻量化了。比如,当我要把Computer换成ComputerV2时,原先的数据依然可以被复用。
Satellite - 分解合约功能
一个复杂的合约通常由许多功能构成,如果这些功能全部耦合在一个合约中,当某一个功能需要更新时,就不得不去部署整个合约,正常的功能都会受到波及。
Satellite模式运用单一职责原则解决上述问题,提倡将合约子功能放到子合约里,每个子合约(也称为卫星合约)只对应一个功能。当某个子功能需要修改,只要创建新的子合约,并将其地址更新到主合约里即可,其余功能不受影响。
举个简单的例子,下面这个合约的setVariable功能是将输入数据进行计算(compute函数),并将计算结果存入合约状态_variable:
contract Base {
uint public _variable;
function setVariable(uint data) public {
_variable = compute(data);
}
//计算
function compute(uint a) internal returns(uint){
return a * 10;
}
}
如果部署后,发现compute函数写错,希望乘以的系数是20,就要重新部署整个合约。但如果一开始按照Satellite模式操作,则只需部署相应的子合约。
首先,我们先将compute函数剥离到一个单独的卫星合约中去:
contract Satellite {
function compute(uint a) public returns(uint){
return a * 10;
}
}
然后,主合约依赖该子合约完成setVariable:
contract Base {
uint public _variable;
function setVariable(uint data) public {
_variable = _satellite.compute(data);
}
Satellite _satellite;
//更新子合约(卫星合约)
function updateSatellite(address addr) public {
_satellite = Satellite(addr);
}
}
这样,当我们需要修改compute函数时,只需部署这样一个新合约,并将它的地址传入到Base.updateSatellite即可:
contract Satellite2{
function compute(uint a) public returns(uint){
return a * 20;
}
}
Contract Registry - 跟踪最新合约
在Satellite模式中,如果一个主合约依赖子合约,在子合约升级时,主合约需要更新对子合约的地址引用,这通过updateXXX来完成,例如前文的updateSatellite函数。
这类接口属于维护性接口,与实际业务无关,过多暴露此类接口会影响主合约美观,让调用者的体验大打折扣。Contract Registry设计模式优雅地解决了这个问题。
在该设计模式下,会有一个专门的合约Registry跟踪子合约的每次升级情况,主合约可通过查询此Registyr合约取得最新的子合约地址。卫星合约重新部署后,新地址通过Registry.update函数来更新。
contract Registry{
address _current;
address[] _previous;
//子合约升级了,就通过update函数更新地址
function update(address newAddress) public{
if(newAddress != _current){
_previous.push(_current);
_current = newAddress;
}
}
function getCurrent() public view returns(address){
return _current;
}
}
主合约依赖于Registry获取最新的卫星合约地址。
contract Base {
uint public _variable;
function setVariable(uint data) public {
Satellite satellite = Satellite(_registry.getCurrent());
_variable = satellite.compute(data);
}
Registry private _registry = //...;
}
Contract Relay - 代理调用最新合约
该设计模式所解决问题与Contract Registry一样,即主合约无需暴露维护性接口就可调用最新子合约。该模式下,存在一个代理合约,和子合约享有相同接口,负责将主合约的调用请求传递给真正的子合约。卫星合约重新部署后,新地址通过SatelliteProxy.update函数来更新。
contract SatelliteProxy{
address _current;
function compute(uint a) public returns(uint){
Satellite satellite = Satellite(_current);
return satellite.compute(a);
}
//子合约升级了,就通过update函数更新地址
function update(address newAddress) public{
if(newAddress != _current){
_current = newAddress;
}
}
}
contract Satellite {
function compute(uint a) public returns(uint){
return a * 10;
}
}
主合约依赖于SatelliteProxy:
contract Base {
uint public _variable;
function setVariable(uint data) public {
_variable = _proxy.compute(data);
}
SatelliteProxy private _proxy = //...;
}
生命周期(Lifecycle)
在默认情况下,一个合约的生命周期近乎无限——除非赖以生存的区块链被消灭。但很多时候,用户希望缩短合约的生命周期。这一节将介绍两个简单模式提前终结合约生命。
Mortal - 允许合约自毁
字节码中有一个selfdestruct指令,用于销毁合约。所以只需要暴露出自毁接口即可:
contract Mortal{
//自毁
function destroy() public{
selfdestruct(msg.sender);
}
}
Automatic Deprecation - 允许合约自动停止服务
如果你希望一个合约在指定期限后停止服务,而不需要人工介入,可以使用Automatic Deprecation模式。
contract AutoDeprecated{
uint private _deadline;
function setDeadline(uint time) public {
_deadline = time;
}
modifier notExpired(){
require(now <= _deadline);
_;
}
function service() public notExpired{
//some code
}
}
当用户调用service,notExpired修饰符会先进行日期检测,这样,一旦过了特定时间,调用就会因过期而被拦截在notExpired层。
权限(Authorization)
前文中有许多管理性接口,这些接口如果任何人都可调用,会造成严重后果,例如上文中的自毁函数,假设任何人都能访问,其严重性不言而喻。所以,一套保证只有特定账户能够访问的权限控制设计模式显得尤为重要。
Ownership
对于权限的管控,可以采用Ownership模式。该模式保证了只有合约的拥有者才能调用某些函数。首先需要有一个Owned合约:
contract Owned{
address public _owner;
constructor() {
_owner = msg.sender;
}
modifier onlyOwner(){
require(_owner == msg.sender);
_;
}
}
如果一个业务合约,希望某个函数只由拥有者调用,该怎么办呢?如下:
contract Biz is Owned{
function manage() public onlyOwner{
}
}
这样,当调用manage函数时,onlyOwner修饰符就会先运行并检测调用者是否与合约拥有者一致,从而将无授权的调用拦截在外。
行为控制(Action And Control)
这类模式一般针对具体场景使用,这节将主要介绍基于隐私的编码模式和与链外数据交互的设计模式。
Commit - Reveal - 延迟秘密泄露
链上数据都是公开透明的,一旦某些隐私数据上链,任何人都可看到,并且再也无法撤回。
Commit And Reveal模式允许用户将要保护的数据转换为不可识别数据,比如一串哈希值,直到某个时刻再揭示哈希值的含义,展露真正的原值。
以投票场景举例,假设需要在所有参与者都完成投票后再揭示投票内容,以防这期间参与者受票数影响。我们可以看看,在这个场景下所用到的具体代码:
contract CommitReveal {
struct Commit {
string choice;
string secret;
uint status;
}
mapping(address => mapping(bytes32 => Commit)) public userCommits;
event LogCommit(bytes32, address);
event LogReveal(bytes32, address, string, string);
function commit(bytes32 commit) public {
Commit storage userCommit = userCommits[msg.sender][commit];
require(userCommit.status == 0);
userCommit.status = 1; // comitted
emit LogCommit(commit, msg.sender);
}
function reveal(string choice, string secret, bytes32 commit) public {
Commit storage userCommit = userCommits[msg.sender][commit];
require(userCommit.status == 1);
require(commit == keccak256(choice, secret));
userCommit.choice = choice;
userCommit.secret = secret;
userCommit.status = 2;
emit LogReveal(commit, msg.sender, choice, secret);
}
}
Oracle - 读取链外数据
目前,链上的智能合约生态相对封闭,无法获取链外数据,影响了智能合约的应用范围。
链外数据可极大扩展智能合约的使用范围,比如在保险业中,如果智能合约可读取到现实发生的意外事件,就可自动执行理赔。
获取外部数据会通过名为Oracle的链外数据层来执行。当业务方的合约尝试获取外部数据时,会先将查询请求存入到某个Oracle专用合约内;Oracle会监听该合约,读取到这个查询请求后,执行查询,并调用业务合约响应接口使合约获取结果。
下面定义了一个Oracle合约:
contract Oracle {
address oracleSource = 0x123; // known source
struct Request {
bytes data;
function(bytes memory) external callback;
}
Request[] requests;
event NewRequest(uint);
modifier onlyByOracle() {
require(msg.sender == oracleSource); _;
}
function query(bytes data, function(bytes memory) external callback) public {
requests.push(Request(data, callback));
emit NewRequest(requests.length - 1);
}
//回调函数,由Oracle调用
function reply(uint requestID, bytes response) public onlyByOracle() {
requests[requestID].callback(response);
}
}
业务方合约与Oracle合约进行交互:
contract BizContract {
Oracle _oracle;
constructor(address oracle){
_oracle = Oracle(oracle);
}
modifier onlyByOracle() {
require(msg.sender == address(_oracle));
_;
}
function updateExchangeRate() {
_oracle.query("USD", this.oracleResponse);
}
//回调函数,用于读取响应
function oracleResponse(bytes response) onlyByOracle {
// use the data
}
}
上链的原则
“如无必要,勿增实体”。
基于区块链技术及智能合约发展现状,数据的上链需遵循以下原则:
需要分布式协作的重要数据才上链,非必需数据不上链;
敏感数据脱敏或加密后上链(视数据保密程度选择符合隐私保护安全等级要求的加密算法);
链上验证,链下授权。
在使用区块链时,开发者不需要将所有业务和数据都放到链上。相反,“好钢用在刀刃上”,智能合约更适合被用在分布式协作的业务场景中。
精简函数变量
如果在智能合约中定义了复杂的逻辑,特别是合约内定义了复杂的函数入参、变量和返回值,就会在编译的时候碰到以下错误:
Compiler error: Stack too deep, try removing local variables.
这也是社区中的高频技术问题之一。造成这个问题的原因就是EVM所设计用于最大的栈深度为16。
所有的计算都在一个栈内执行,对栈的访问只限于其顶端,限制方式为:允许拷贝最顶端16个元素中的一个到栈顶,或者将栈顶元素和下面16个元素中的一个交换。
所有其他操作都只能取最顶的几个元素,运算后,把结果压入栈顶。当然可以把栈上的元素放到存储或内存中。但无法只访问栈上指定深度的那个元素,除非先从栈顶移除其他元素。如果一个合约中,入参、返回值、内部变量的大小超过了16个,显然就超出了栈的最大深度。
因此,我们可以使用结构体或数组来封装入参或返回值,达到减少栈顶元素使用的目的,从而避免此错误。
例如以下代码,通过使用bytes数组来封装了原本16个bytes变量。
function doBiz(bytes[] paras) public {
require(paras.length >= 16);
// do something
}
保证参数和行为符合预期
心怀“Code is law”的远大理想,极客们设计和创造了区块链的智能合约。
在联盟链中,不同的参与者可以使用智能合约来定义和书写一部分业务或交互的逻辑,以完成部分社会或商业活动。
相比于传统软件开发,智能合约对函数参数和行为的安全性要求更为严格。在联盟链中提供了身份实名和CA证书等机制,可以有效定位和监管所有参与者。不过,智能合约缺乏对漏洞和攻击的事前干预机制。正所谓字字珠玑,如果不严谨地检查智能合约输入参数或行为,有可能会触发一些意想不到的bug。
因此,在编写智能合约时,一定要注意对合约参数和行为的检查,尤其是那些对外部开放的合约函数。
Solidity提供了require、revert、assert等关键字来进行异常的检测和处理。一旦检测并发现错误,整个函数调用会被回滚,所有状态修改都会被回退,就像从未调用过函数一样。
以下分别使用了三个关键字,实现了相同的语义。
require(_data == data, "require data is valid");
if(_data != data) { revert("require data is valid"); }
assert(_data == data);
不过,这三个关键字一般适用于不同的使用场景:
require:最常用的检测关键字,用来验证输入参数和调用函数结果是否合法。
revert:适用在某个分支判断的场景下。
assert: 检查结果是否正确、合法,一般用于函数结尾。
在一个合约的函数中,可以使用函数修饰器来抽象部分参数和条件的检查。在函数体内,可以对运行状态使用if-else等判断语句进行检查,对异常的分支使用revert回退。在函数运行结束前,可以使用assert对执行结果或中间状态进行断言检查。
在实践中,推荐使用require关键字,并将条件检查移到函数修饰器中去;这样可以让函数的职责更为单一,更专注到业务逻辑中。同时,函数修饰器等条件代码也更容易被复用,合约也会更加安全、层次化。
在本文中,我们以一个水果店库存管理系统为例,设计一个水果超市的合约。这个合约只包含了对店内所有水果品类和库存数量的管理,setFruitStock函数提供了对应水果库存设置的函数。在这个合约中,我们需要检查传入的参数,即水果名称不能为空。
pragma solidity ^0.4.25;
contract FruitStore {
mapping(bytes => uint) _fruitStock;
modifier validFruitName(bytes fruitName) {
require(fruitName.length > 0, "fruite name is invalid!");
_;
}
function setFruitStock(bytes fruitName, uint stock) validFruitName(fruitName) external {
_fruitStock[fruitName] = stock;
}
}
如上所述,我们添加了函数执行前的参数检查的函数修饰器。同理,通过使用函数执行前和函数执行后检查的函数修饰器,可以保证智能合约更加安全、清晰。智能合约的编写需要设置严格的前置和后置函数检查,来保证其安全性。
严控函数的执行权限
如果说智能合约的参数和行为检测提供了静态的合约安全措施,那么合约权限控制的模式则提供了动态访问行为的控制。
由于智能合约是发布到区块链上,所有数据和函数对所有参与者都是公开透明的,任一节点参与者都可发起交易,无法保证合约的隐私。因此,合约发布者必须对函数设计严格的访问限制机制。
Solidity提供了函数可见性修饰符、修饰器等语法,灵活地使用这些语法,可帮助构建起合法授权、受控调用的智能合约系统。
还是以刚才的水果合约为例。现在getStock提供了查询具体水果库存数量的函数。
pragma solidity ^0.4.25;
contract FruitStore {
mapping(bytes => uint) _fruitStock;
modifier validFruitName(bytes fruitName) {
require(fruitName.length > 0, "fruite name is invalid!");
_;
}
function getStock(bytes fruit) external view returns(uint) {
return _fruitStock[fruit];
}
function setFruitStock(bytes fruitName, uint stock) validFruitName(fruitName) external {
_fruitStock[fruitName] = stock;
}
}
水果店老板将这个合约发布到了链上。但是,发布之后,setFruitStock函数可被任何其他联盟链的参与者调用。
虽然联盟链的参与者是实名认证且可事后追责;但一旦有恶意攻击者对水果店发起攻击,调用setFruitStock函数就能任意修改水果库存,甚至将所有水果库存清零,这将对水果店正常经营管理产生严重后果。
因此,设置某些预防和授权的措施很必要:对于修改库存的函数setFruitStock,可在函数执行前对调用者进行鉴权。
类似的,这些检查可能会被多个修改数据的函数复用,使用一个onlyOwner的修饰器就可以抽象此检查。_owner字段代表了合约的所有者,会在合约构造函数中被初始化。使用public修饰getter查询函数,就可以通过_owner()函数查询合约的所有者。
contract FruitStore {
address public _owner;
mapping(bytes => uint) _fruitStock;
constructor() public {
_owner = msg.sender;
}
modifier validFruitName(bytes fruitName) {
require(fruitName.length > 0, "fruite name is invalid!");
_;
}
// 鉴权函数修饰器
modifier onlyOwner() {
require(msg.sender == _owner, "Auth: only owner is authorized.");
_;
}
function getStock(bytes fruit) external view returns(uint) {
return _fruitStock[fruit];
}
// 添加了onlyOwner修饰器
function setFruitStock(bytes fruitName, uint stock)
onlyOwner validFruitName(fruitName) external {
_fruitStock[fruitName] = stock;
}
}
这样一来,我们可以将相应的函数调用权限检查封装到修饰器中,智能合约会自动发起对调用者身份验证检查,并且只允许合约部署者来调用setFruitStock函数,以此保证合约函数向指定调用者开放。
抽象通用的业务逻辑
分析上述FruitStore合约,我们发现合约里似乎混入了奇怪的东西。参考单一职责的编程原则,水果店库存管理合约多了上述函数功能检查的逻辑,使合约无法将所有代码专注在自身业务逻辑中。
对此,我们可以抽象出可复用的功能,利用Solidity的继承机制继承最终抽象的合约。
基于上述FruitStore合约,可抽象出一个BasicAuth合约,此合约包含之前onlyOwner的修饰器和相关功能接口。
contract BasicAuth {
address public _owner;
constructor() public {
_owner = msg.sender;
}
function setOwner(address owner)
public
onlyOwner
{
_owner = owner;
}
modifier onlyOwner() {
require(msg.sender == _owner, "BasicAuth: only owner is authorized.");
_;
}
}
FruitStore可以复用这个修饰器,并将合约代码收敛到自身业务逻辑中。
import "./BasicAuth.sol";
contract FruitStore is BasicAuth {
mapping(bytes => uint) _fruitStock;
function setFruitStock(bytes fruitName, uint stock)
onlyOwner validFruitName(fruitName) external {
_fruitStock[fruitName] = stock;
}
}
这样一来,FruitStore的逻辑被大大简化,合约代码更精简、聚焦和清晰。
预防私钥的丢失
在区块链中调用合约函数的方式有两种:内部调用和外部调用。
出于隐私保护和权限控制,业务合约会定义一个合约所有者。假设用户A部署了FruitStore合约,那上述合约owner就是部署者A的外部账户地址。这个地址由外部账户的私钥计算生成。
但是,在现实世界中,私钥泄露、丢失的现象比比皆是。一个商用区块链DAPP需要严肃考虑私钥的替换和重置等问题。
这个问题最为简单直观的解决方法是添加一个备用私钥。这个备用私钥可支持权限合约修改owner的操作,代码如下:
contract BasicAuth {
address public _owner;
address public _bakOwner;
constructor(address bakOwner) public {
_owner = msg.sender;
_bakOwner = bakOwner;
}
function setOwner(address owner)
public
canSetOwner
{
_owner = owner;
}
function setBakOwner(address owner)
public
canSetOwner
{
_bakOwner = owner;
}
// ...
modifier isAuthorized() {
require(msg.sender == _owner || msg.sender == _bakOwner, "BasicAuth: only owner or back owner is authorized.");
_;
}
}
这样,当发现私钥丢失或泄露时,我们可以使用备用外部账户调用setOwner重置账号,恢复、保障业务正常运行。
面向接口编程
上述私钥备份理念值得推崇,不过其具体实现方式存在一定局限性,在很多业务场景下,显得过于简单粗暴。
对于实际的商业场景,私钥的备份和保存需要考虑的维度和因素要复杂得多,对应密钥备份策略也更多元化。
以水果店为例,有的连锁水果店可能希望通过品牌总部来管理私钥,也有的可能通过社交关系重置帐号,还有的可能会绑定一个社交平台的管理帐号……
面向接口编程,而不依赖具体的实现细节,可以有效规避这个问题。例如,我们利用接口功能首先定义一个判断权限的抽象接口:
contract Authority {
function canCall(
address src, address dst, bytes4 sig
) public view returns (bool);
}
这个canCall函数涵盖了函数调用者地址、目标调用合约的地址和函数签名,函数返回一个bool的结果。这包含了合约鉴权所有必要的参数。
我们可进一步修改之前的权限管理合约,并在合约中依赖Authority接口,当鉴权时,修饰器会调用接口中的抽象方法:
contract BasicAuth {
Authority public _authority;
function setAuthority(Authority authority)
public
auth
{
_authority = authority;
}
modifier isAuthorized() {
require(auth(msg.sender, msg.sig), "BasicAuth: only owner or back owner is authorized.");
_;
}
function auth(address src, bytes4 sig) public view returns (bool) {
if (src == address(this)) {
return true;
} else if (src == _owner) {
return true;
} else if (_authority == Authority(0)) {
return false;
} else {
return _authority.canCall(src, this, sig);
}
}
}
这样,我们只需要灵活定义实现了canCall接口的合约,在合约的canCall方法中定义具体判断逻辑。而业务合约,例如FruitStore继承BasicAuth合约,在创建时只要传入具体的实现合约,就可以实现不同判断逻辑。
合理预留事件
迄今为止,我们已实现强大灵活的权限管理机制,只有预先授权的外部账户才能修改合约owner属性。
不过,仅通过上述合约代码,我们无法记录和查询修改、调用函数的历史记录和明细信息。而这样的需求在实际业务场景中比比皆是。比如,FruitStore水果店需要通过查询历史库存修改记录,计算出不同季节的畅销与滞销水果。
一种方法是依托链下维护独立的台账机制。不过,这种方法存在很多问题:保持链下台账和链上记录一致的成本开销非常高;同时,智能合约面向链上所有参与者开放,一旦其他参与者调用了合约函数,相关交易信息就存在不能同步的风险。
针对此类场景,Solidity提供了event语法。event不仅具备可供SDK监听回调的机制,还能用较低的gas成本将事件参数等信息完整记录、保存到区块中。FISCO BCOS社区中,也有WEBASE-Collect-Bee这样的工具,在事后实现区块历史事件信息的完整导出。
WEBASE-Collect-Bee工具参考链接如下:
https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Collect-Bee/index.html
基于上述权限管理合约,我们可以定义相应的修改权限事件,其他事件以此类推。
event LogSetAuthority (Authority indexed authority, address indexed from);
}
接下来,可以调用相应的事件:
function setAuthority(Authority authority)
public
auth
{
_authority = authority;
emit LogSetAuthority(authority, msg.sender);
}
当setAuthority函数被调用时,会同时触发LogSetAuthority,将事件中定义的Authority合约地址以及调用者地址记录到区块链交易回执中。当通过控制台调用setAuthority方法时,对应事件LogSetAuthority也会被打印出来。
基于WEBASE-Collect-Bee,我们可以导出所有该函数的历史信息到数据库中。也可基于WEBASE-Collect-Bee进行二次开发,实现复杂的数据查询、大数据分析和数据可视化等功能。
遵循安全编程规范
每一门语言都有其相应的编码规范,我们需要尽可能严格地遵循Solidity官方编程风格指南,使代码更利于阅读、理解和维护,有效地减少合约的bug数量。
Solidity官方编程风格指南参考链接如下:
https://solidity.readthedocs.io/en/latest/style-guide.html
除了编程规范,业界也总结了很多安全编程指南,例如重入漏洞、数据结构溢出、随机数误区、构造函数失控、为初始化的存储指针等等。重视和防范此类风险,采用业界推荐的安全编程规范至关重要,例如Solidity官方安全编程指南。参考链接如下:
https://solidity.readthedocs.io/en/latest/security-considerations.html
同时,在合约发布上线后,还需要注意关注、订阅Solidity社区内安全组织或机构发布的各类安全漏洞、攻击手法,一旦出现问题,及时做到亡羊补牢。
对于重要的智能合约,有必要引入审计。现有的审计包括了人工审计、机器审计等方法,通过代码分析、规则验证、语义验证和形式化验证等方法保证合约安全性。
虽然本文通篇都在强调,模块化和重用被严格审查并广泛验证的智能合约是最佳的实践策略。但在实际开发过程,这种假设过于理想化,每个项目或多或少都会引入新的代码,甚至从零开始。
不过,我们仍然可以视代码的复用程度进行审计分级,显式地标注出引用的代码,将审计和检查的重点放在新代码上,以节省审计成本。
最后,“前事不忘后事之师”,我们需要不断总结和学习前人的最佳实践,动态和可持续地提升编码工程水平,并不断应用到具体实践中。
积累和复用成熟的代码
前文面向接口编程中的思想可降低代码耦合,使合约更容易扩展、利于维护。在遵循这条规则之外,还有另外一条忠告:尽可能地复用现有代码库。
智能合约发布后难以修改或撤回,而且发布到公开透明的区块链环境上,就意味着一旦出现bug造成的损失和风险更甚于传统软件。因此,复用一些更好更安全的轮子远胜过重新造轮子。
在开源社区中,已经存在大量的业务合约和库可供使用,例如OpenZeppelin等优秀的库。
如果在开源世界和过去团队的代码库里找不到合适的可复用代码,建议在编写新代码时尽可能地测试和完善代码设计。此外,还要定期分析和审查历史合约代码,将其模板化,以便于扩展和复用。
例如,针对上面的BasicAuth,参考防火墙经典的ACL(Access Control List)设计,我们可以进一步地继承和扩展BasicAuth,抽象出ACL合约控制的实现。
contract AclGuard is BasicAuth {
bytes4 constant public ANY_SIG = bytes4(uint(-1));
address constant public ANY_ADDRESS = address(bytes20(uint(-1)));
mapping (address => mapping (address => mapping (bytes4 => bool))) _acl;
function canCall(
address src, address dst, bytes4 sig
) public view returns (bool) {
return _acl[src][dst][sig]
|| _acl[src][dst][ANY_SIG]
|| _acl[src][ANY_ADDRESS][sig]
|| _acl[src][ANY_ADDRESS][ANY_SIG]
|| _acl[ANY_ADDRESS][dst][sig]
|| _acl[ANY_ADDRESS][dst][ANY_SIG]
|| _acl[ANY_ADDRESS][ANY_ADDRESS][sig]
|| _acl[ANY_ADDRESS][ANY_ADDRESS][ANY_SIG];
}
function permit(address src, address dst, bytes4 sig) public onlyAuthorized {
_acl[src][dst][sig] = true;
emit LogPermit(src, dst, sig);
}
function forbid(address src, address dst, bytes4 sig) public onlyAuthorized {
_acl[src][dst][sig] = false;
emit LogForbid(src, dst, sig);
}
function permit(address src, address dst, string sig) external {
permit(src, dst, bytes4(keccak256(sig)));
}
function forbid(address src, address dst, string sig) external {
forbid(src, dst, bytes4(keccak256(sig)));
}
function permitAny(address src, address dst) external {
permit(src, dst, ANY_SIG);
}
function forbidAny(address src, address dst) external {
forbid(src, dst, ANY_SIG);
}
}
在这个合约里,有调用者地址、被调用合约地址和函数签名三个主要参数。通过配置ACL的访问策略,可以精确地定义和控制函数访问行为及权限。合约内置了ANY的常量,匹配任意函数,使访问粒度的控制更加便捷。这个模板合约实现了强大灵活的功能,足以满足所有类似权限控制场景的需求。
提升存储和计算的效率
迄今为止,在上述的推演过程中,更多的是对智能合约编程做加法。但相比传统软件环境,智能合约上的存储和计算资源更加宝贵。因此,如何对合约做减法也是用好Solidity的必修课程之一。
选取合适的变量类型
显式的问题可通过EVM编译器检测出来并报错;但大量的性能问题可能被隐藏在代码的细节中。
Solidity提供了非常多精确的基础类型,这与传统的编程语言大相径庭。下面有几个关于Solidity基础类型的小技巧。
在C语言中,可以用short\\int\\long按需定义整数类型,而到了Solidity,不仅区分int和uint,甚至还能定义uint的长度,比如uint8是一个字节,uint256是32个字节。这种设计告诫我们,能用uint8搞定的,绝对不要用uint16!
几乎所有Solidity的基本类型,都能在声明时指定其大小。开发者一定要有效利用这一语法特性,编写代码时只要满足需求就尽可能选取小的变量类型。
数据类型bytes32可存放 32 个(原始)字节,但除非数据是bytes32或bytes16这类定长的数据类型,否则更推荐使用长度可以变化的bytes。bytes类似byte[],但在外部函数中会自动压缩打包,更节省空间。
如果变量内容是英文的,不需要采用UTF-8编码,在这里,推荐bytes而不是string。string默认采用UTF-8编码,所以相同字符串的存储成本会高很多。
紧凑状态变量打包
除了尽可能使用较小的数据类型来定义变量,有的时候,变量的排列顺序也非常重要,可能会影响到程序执行和存储效率。
其中根本原因还是EVM,不管是EVM存储插槽(Storage Slot)还是栈,每个元素长度是一个字(256位,32字节)。
分配存储时,所有变量(除了映射和动态数组等非静态类型)都会按声明顺序从位置0开始依次写下。
在处理状态变量和结构体成员变量时,EVM会将多个元素打包到一个存储插槽中,从而将多个读或写合并到一次对存储的操作中。
值得注意的是,使用小于32 字节的元素时,合约的gas使用量可能高于使用32字节元素时。这是因为EVM每次会操作32个字节,所以如果元素比32字节小,必须使用更多的操作才能将其大小缩减到所需。这也解释了Solidity中最常见的数据类型,例如int,uint,byte32,为何都刚好占用32个字节。
所以,当合约或结构体声明多个状态变量时,能否合理地组合安排多个存储状态变量和结构体成员变量,使之占用更少的存储位置就十分重要。
例如,在以下两个合约中,经过实际测试,Test1合约比Test2合约占用更少的存储和计算资源。
contract Test1 {
//占据2个slot, "gasUsed":188873
struct S {
bytes1 b1;
bytes31 b31;
bytes32 b32;
}
S s;
function f() public {
S memory tmp = S("a","b","c");
s = tmp;
}
}
contract Test2 {
//占据1个slot, "gasUsed":188937
struct S {
bytes31 b31;
bytes32 b32;
bytes1 b1;
}
// ……
}
优化查询接口
查询接口的优化点很多,比如一定要在只负责查询的函数声明中添加view修饰符,否则查询函数会被当成交易打包并发送到共识队列,被全网执行并被记录在区块中;这将大大增加区块链的负担,占用宝贵的链上资源。
再如,不要在智能合约中添加复杂的查询逻辑,因为任何复杂查询代码都会使整个合约变得更长更复杂。读者可使用上文提及的WeBASE数据导出组件,将链上数据导出到数据库中,在链下进行查询和分析。
缩减合约binary长度
开发者编写的Solidity代码会被编译为binary code,而部署智能合约的过程实际上就是通过一个transaction将binary code存储在链上,并取得专属于该合约的地址。
缩减binary code的长度可节省网络传输、共识打包数据存储的开销。例如,在典型的存证业务场景中,每次客户存证都会新建一个存证合约,因此,应当尽可能地缩减binary code的长度。
常见思路是裁剪不必要的逻辑,删掉冗余代码。特别是在复用代码时,可能引入一些非刚需代码。以上文ACL合约为例,支持控制合约函数粒度的权限。
function canCall(
address src, address dst, bytes4 sig
) public view returns (bool) {
return _acl[src][dst][sig]
|| _acl[src][dst][ANY_SIG]
|| _acl[src][ANY_ADDRESS][sig]
|| _acl[src][ANY_ADDRESS][ANY_SIG]
|| _acl[ANY_ADDRESS][dst][sig]
|| _acl[ANY_ADDRESS][dst][ANY_SIG]
|| _acl[ANY_ADDRESS][ANY_ADDRESS][sig]
|| _acl[ANY_ADDRESS][ANY_ADDRESS][ANY_SIG];
}
但在具体业务场景中,只需要控制合约访问者即可,通过删除相应代码,进一步简化使用逻辑。这样一来,对应合约的binary code长度会大大缩小。
function canCall(
address src, address dst
) public view returns (bool) {
return _acl[src][dst]
|| _acl[src][ANY_ADDRESS]
|| _acl[ANY_ADDRESS][dst];
另一种缩减binary code的思路是采用更紧凑的写法。
经实测,采取如上短路原则的判断语句,其binary长度会比采用if-else语法的更短。同样,采用if-else的结构,也会比if-if-if的结构生成更短的binary code。
最后,在对binary code长度有极致要求的场景中,应当尽可能避免在合约中新建合约,这会显著增加binary的长度。例如,某个合约中有如下的构造函数:
constructor() public {
// 在构造器内新建一个新对象
_a = new A();
我们可以采用在链下构造A对象,并基于address传输和固定校验的方式,来规避这一问题。
constructor(address a) public {
A _a = A(a);
require(_a._owner == address(this));
当然,这样也可能会使合约交互方式变得复杂。但其提供了有效缩短binary code长度的捷径,需要在具体业务场景中做权衡取舍。
保证合约可升级
经典的三层结构
通过前文方式,我们尽最大努力保持合约设计的灵活性;翻箱倒柜复用了轮子;也对发布合约进行全方位、无死角的测试。除此之外,随着业务需求变化,我们还将面临一个问题:如何保证合约平滑、顺利的升级?
作为一门高级编程语言,Solidity支持运行一些复杂控制和计算逻辑,也支持存储智能合约运行后的状态和业务数据。不同于WEB开发等场景的应用-数据库分层架构,Solidity语言甚至没有抽象出一层独立的数据存储结构,数据都被保存到了合约中。
但是,一旦合约需要升级,这种模式就会出现瓶颈。
在Solidity中,一旦合约部署发布后,其代码就无法被修改,只能通过发布新合约去改动代码。假如数据存储在老合约,就会出现所谓的“孤儿数据”问题,新合约将丢失之前运...
热门跟贴