关于以太坊智能合约在项目实战过程中的设计及经验总结(1)

叁叁肆2018-12-07 15:47

此文已由作者苏州授权网易云社区发布。

欢迎访问网易云社区,了解更多网易技术产品运营经验


1.智能合约的概述
近几年,区块链概念的大风吹遍了全球各地,有的人觉得这是一个大风口,有的人觉得他是个泡沫。众所周知,比特币是区块链1.0,而以太坊被称为了区块链2.0,而区块链1.0和2.0最主要的差别就在于以太坊拥有了智能合约。其实,智能合约在1994年就已出现,计算机科学家和密码学家NickSzabo首次提出智能合约概念。早于区块链概念的诞生。Szabo描述了什么是“以数字形式指定的一系列承诺,包括各方履行这些许诺”的协议。虽然有它好处,但智能合约的想法一直未取得进展—主要是缺乏可以让它发挥出作用的区块链。
从技术角度理解,智能合约其实是一个语法简单、指令集精简的图灵完备的语言,就像简化版的JavaScript。智能合约和其他的语言的区别主要在于,一方面,智能合约和代币体系完美结合,能够完成一系列价值转移,另一方面,智能合约会在所有节点统一执行,根据确定的输入、确定的代码保证确定的输出,也是所有节点状态一致性的保证。最后是智能合约都由有外部触发调用,不存在什么定时调用等。
废话不多说,接下来,本人就从技术角度,来说说智能合约方面的设计。


2.智能合约的分层设计
2.1分层设计说明
智能合约的分层设计模型主要是借鉴gitHub上的一篇名为《 浅谈以太访智能合约的设计模式与升级方法》文章的中心思想,其作者也是基于其多年的Java实战经验提出的一些智能合约设计思路。该文章有许多借鉴之处,但也存在许多坑点没有仔细考虑。文章的分层设计思路主要如下:
“业务逻辑与外部解耦、业务逻辑与数据解耦”是Java设计模式的一种策略,也是其文章的主要思想。其实现方式主要将合约拆分为代理合约、业务控制合约、业务数据合约、命名控制器合约。其中代理合约是用于业务逻辑与外部Dapp的解耦,业务控制合约、业务数据合约和命名控制器合约是用于业务逻辑与数据的解耦。作者在设计时,也拆分了几种不同的场景,详见如下:
控制器合约与数据合约1—>1:
控制器合约与数据合约1—>N:
控制器合约与数据合约N—>1:
控制器合约与数据合约N—>N:
此类情况可以拆解为上面三种情况的组合。
2.2分层设计实现关键点
1)合约与合约之间的调用
合约调用合约的实现主要有两种方式,第一种方式是可以通过 call、delegatecall、 callcode方法实现对其他合约的方法的调用,但是其弊端是使用存在安全性问题,而且不能获知被调用合约的执行结果,不建议使用。第二种方式,是通过在合约中“外部引用”被调用的外部合约进行实现。
通过合约“外部引用”实现调用外部合约需要注意以下几点:
  • 合约对象中需要定义被应用合约对象的方法,否则合约中无法识别被应用对象,编译器会报错;
  • 被引用对象需要通过合约对象的设置外部合约方法将合约对象进行引入,注意需要引入外部合约对象后。
2)合约与合约之间的转账
合约可以接收转账,需要显示声明回调函数,并在回调函数上加payable进行修饰。合约与合约之间进行转账时,需要在合约中显示用send或者transfer进行合约之间的转账,合约与合约之间的转账将以内部交易的形式执行。另外,在显示转账的方法中也需要加payable修饰。
pragma solidity ^0.4.2;
contract  Test{
function TTest(address contractAddress,uint amount)  payable {
   contractAddress.transfer(amount);
    }
    function()  payable {
    }
}

2.3分层设计的局限与问题
1)被调用合约的方法的数据返回限制
被调用合约在返回string/bytes等不定长类型时会存在问题。这种限制需要在设计被调用合约时要注意,在实际项目中业务逻辑合约和数据合约都属于被调用合约,故而其设计公共方法时需要规避string/bytes等不定长的限制问题。以下是一个调用失败的反例:
pragma solidity ^0.4.2;
contract  Test{
function TTest(address contractAddress,uint amount) {
   A a=A(contractAddress);
  //编译会报错
   string temp=a.getString();
    }
}
2)被调用合约的方法的返回参数长度限制
被调用合约在返回定长的数据时,不能返回超过32位长度的数据,例如bytes33/uint33编译器将会提示错误。
3)被调用合约结构体数据返回限制
Solidity语言中,在编译器0.4.17版本之后,可以支持struct结构体的数据返回。在返回结构体的情况下,编码需要注意添加“pragma experimental ABIEncoderV2;”,需要注意的是结构体中也不能包含string/bytes等不定长数据类型,但是返回struct这种形式还处于试验阶段,稳定性安全性有待论证。(在0.4.17版本之前不能使用因为以前编译器没有把struct作为一个真正的类,只是形式上的组合在一起)
pragma solidity ^0.4.17;
pragma experimental ABIEncoderV2;
contract  Test{
    struct MyStruct { int key; uint deleted; }
function TTest() returns() {
   return MyStruct({key:int(1),deleted:uint(1)});
    }
}
4)被调用合约返回合约类型的限制
被调用合约能够返回合约类型的数据,编译器将合约看成地址返回,而地址是定长的。
5)被调用合约事件监听的问题
如果被调用合约需要触发事件,可能会存在事件监听的问题。如果通过web3j监听区块链的事件,被调用的合约事件信息可能会被编码,故而可能导致web3j无法监听到被调用合约内部触发的事件。问题原因为在定义的接口合约中没有相关的事件声明。(详见附录实例代码)例如以下是本人测试返回的事件信息:
//被调用合约的事件监听返回数据
{
"data": "0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000bbf289d846208c16edc8474705c748aff07732db000000000000000000000000000000000000000000000000000000000000000e5365727669636520446f2e2e2e2e000000000000000000000000000000000000",
"topics": [
"4d3a2e6362f7a2697702c4af6f5a55dbb398da05784a12752d3cb5e12dcbf965"
]
}
6)智能合约的传入参数大小限制
智能合约在传入参数方面存在着EVM虚拟机栈的限制,默认情况下EVM虚拟机栈的大小为1024*512bit,故而参数不能超过这个大小,否则会出现虚拟机栈。如果涉及的业务数据不大的情况下,可以在链上保存,若涉及的业务数据比较大,建议通过链外进行业务数据的交互。当下针对大业务数据比较好的一种解决方案是,通过IPFS文件系统存储线外数据。
(备注:IPFS(InterPlanetary File System,星际文件系统)是一个旨在创建持久且分布式存储和共享文件的网络传输协议。它是一种内容可寻址的对等超媒体分发协议。)
7)智能合约方法中局部变量的数量限制
在智能合约编程中,Solidity编译器不允许方法的超过16个“局部变量”,否则编译器将会报错。其中方法“局部变量”的计算规则自定义局部变量算1个,每个传入传出参数算1个,外部合约调用算2个,总计不能超过16个,否则会出现“Stack Too Deep”的编译错误。
8)合约依赖外部库或者外部合约时部署限制
当某合约依赖外部库函数或者外部合约时,其部署合约时需要先部署外部库函数或者外部合约,将部署后得到的外部库或者外部合约地址设置到该合约的abi文件中。解决方式有两种,一种通过在该函数中定义设置外部函数或者外部合约的地址的方法,手动设置;另一种是通过Truffle框架,其提供了依赖部署的方式,详见《Truffle使用手册》。
9)合约执行的出现Invalid Code的问题
针对使用assert断言或者require的函数修饰器(例如权限控制、启停控制等)程序判断不通过,将会执行revert语句,而revert语句在当前版本被认定为Invalid Code。
另外,说明下assert语句和require的区别:用了assert的话,则程序的gas limit会消耗完毕;而require的话,则只是消耗掉当前执行的gas。


3.智能合约的数据迁移
数据迁移问题也是设计系统必须要考虑的问题,而区块链的特点就是数据拥有不可窜改的特点,也就造就了智能合约数据迁移的困难。
1)继承式数据迁移法
新版本的数据合约中保存一个指向旧版本数据合约的合约地址,新版本数据合约保存的是增量的数据内容。该方法要求合约能够分层设计,将数据部分的合约独立出来。
2)日志式数据回放法
注意我们不能新建一条链,并发所有的交易进行重放。此处指的是,在合约中通过event事件、结构体记录数据的状态及变化,必要时,能够新建一个合约并重新初始化同样的数据。


4.智能合约补救策略设计
编写以太访智能合约难免可能存在一些漏洞,假如系统遭受攻击形成资金损失,可以通过如下处理方式:
1)合约暂停或者销毁
在合约编码的时候,一定要给每一个合约加上停止或者销毁的方法,便于在第一时间发现合约出现错误或者漏洞时损失的“停滞”。在暂停和销毁方法选择方面,本人建议都使用暂停方式,因为方法被暂停后调用方能够得到明确的异常通知,但是合约被销毁后,合约就不存在了,这时调用方继续调用会得不到异常反馈,且发送的资金也将永远不能被追回。
2)通过硬分叉
强制硬分叉,或者强制进行块数据回滚适用于联盟链的角度;并重新发布新合约;
3)合约数据迁移,重新发布新合约
在实践项目中,建议设置方法的启停开关,在出现异常情况下,可以及时停止合约方法,避免合约问题扩散。通过合约数据的迁移方式,重新发布问题合约。如果是代币,就重新发行新的代币,适用于公链或者联盟链。


5.智能合约安全性问题规避
1)合约之间的转账send方法使用问题
在合约之间进行转账操作时,如果使用<address>.send(value)方法时,该方法需要进行返回结果的判断,如果返回结果为false需要人工抛出异常,然后阻止后续流程,否则转账异常后返回false还是会继续执行后续流程,这种方式也能避免call deep合约攻击。
pragma solidity ^0.4.2;
contract  Test{
function TTest(address contractAddress,uint amount)  payable {
   if(!contractAddress.send(amount)){throw;}
    }
}
2)合约的权限控制问题
合约的分层设计中,需要对依赖的外部合约进行手动注入,故而需要注意在合约的关键方法上进行权限控制,规避其他人能改变合约的调用关系,从而系统被攻击。
3)call、delegatecall、callcode方法使用问题
不建议在合约中使用call、delegatecall、callcode方法,因为这些方法能够调用代码未知,从而导致风险未知。
4)调用外部合约的顺序问题
在实现合约调用合约的模式中,需要注意的是,优先完成内部交易逻辑,将外部调用放在后面进行操作,这样可以避免call deep攻击。例如:Solidity官网文档中提到的Withdrawal模式。
5)交易执行顺序问题
交易顺序依赖就是智能合约的执行随着当前交易处理的顺序不同而产生差异。在智能合约设计时需要考虑,交易的顺序性以及如何串联交易流程,例如通过设置全局业务的唯一标识。
6)问题合约的防范策略
每个智能合约都不是百分百的完美,可能会存在一些漏洞或者Bug,针对有问题的合约,我们需要第一时间能进行对合约的控制。比如在合约中增加“销毁函数”,第一时间销毁有问题合约,不过这种方式比较粗暴。另一种方式,在合约方法中加入“启停”控制,当发现问题时,第一时间将合约的方法停止,然后尽快升级新合约,避免问题的蔓延。
7)被调用合约方法访问的约束策略
因为每个调用的合约一般是有明确的调用的对象的,比如代理合约调用业务合约,那么就应该业务合约智能被代理合约调用,否则其他人只要知道了业务合约的地址,其也可直接发起调用,对合约的安全性存在影响。

6.智能合约实战问题记录
除了以上关于一些限制性的问题和安全漏洞方面的问题,在项目实战过程中还遇到了一些其他问题,此处不再分类,一并记录:
1)在用web3j调用的合约中含有自定义外部library的函数应用时,函数的监听无效,或者函数调用失败?
问题原因:是因为当前合约在部署时需要依赖library部署后的地址,而用web3j部署合约时并未依赖library的地址,从而导致当前合约中的library无法调用,从而引发在引入library的函数中事件及方法都调用失败。
解决方式:1.通过增加设置library地址的函数,手动设置;2.通过Truffle等框架的依赖部署功能部署函数。
2)根据Solidity编译后的abi文件能够反编译为Solidity源码?
关于反编译Solidity代码的问题,现在是没有Solidity反编译器的,需要付出极大的努力才能使其看起来与原始源代码相似,只能通过看字节码反编译操作码,看程序的执行逻辑。
3)EVM在执行智能合约时,事物的回滚和提交的触发条件?
EVM在执行是能合约时在以下情况会进行抛出异常,进行回滚;因为EVM首先在快照(默克尔树)中执行代码,如果出现异常回滚将当前的快照回滚至原先的状态,回滚也会包括已经执行的金额退回给原账户,但是需要注意的是事物回滚还是会扣取执行交易消耗的gas费用,事物回滚异常如下:
  • Gas不够,抛出OutOfGasException,细分为以下三种;

                 -notEnoughOpGas
                 -notEnoughSpendingGas
                 -gasOverflow

  • 指令非法,抛出IllegalOperationException;
  • 寻址错误,抛出BadJumpDestinationException;
  • 栈太小,抛出StackTooSmallException;
  • 栈太大,抛出StackTooLargeException。
EVM在正确执行完以下指令,才能进行事物提交:
  •  执行完STOP执令;
  •  执行完RETURN执令;
  •  执行完SUICIDE指令。
4)关于合约自毁后合约地址上的资金问题?
合约在进行自毁操作后,需要提供一个资金转向的地址,合约上的资金会转入该地址当中。另外,如果有账户向销毁后的合约地址发送资金,将导致该笔资金被“冻结”且无法被追回的情况。
5)调用智能合约一个不存在的方法的不报错?
当调用一个外部合约时,且调用的方法不存在,包括方法名和方法参数没有匹配上时,Solidity会默认执行回调函数,回调函数如果不显示声明的情况下为一个没有方法名和返回参数的函数。
6)Remix无法连接EthereumJ测试链的问题?
首先在EthereumJ实现RPC的前提下(默认github源码是没有实现的),如果发现EthereumJ不能连接Remix是因为Remix先会发OPTIONS的请求“探测”下测试链,“探测”通过后在发net_listening的Post请求,所以在实现RPC请求时需要也实现OPTIONS请求方法,另外需要同时在Remix界面中打开listen on network。
如果Remix无法创建账户,请在Remix的Setting中勾选“Always use Ethereum VM at load”和“Enable Personal Mode”。
7)智能合约中是否存在随机函数,或者不同的机器获取的now时间不一致导致程序结果不一致?
在Solidity语言中规避了随机函数的存在,其设计的思想也是通过保障在同样的输入条件、程序代码的情况下能得到一样的结果,这也是每个以太访节点的数据一致性的保证。在获取时间这个点上,now函数不是获取的系统的默认时间,而是取至block块的时间戳,从而每个节点在收到网络中传播的块时,其获取到的now时间都是一样的。
8)EthereumJ中指定监听合约地址无效,还是能监听到其他合约地址触发的事件?
因为在创建事件监听的时候,("address":["0x41bd05db83ed0645fac0995b11e8b734d7711b5c"]),地址被封装为List对象,EthereumJ会匹配address参数对象类型,List因为不能被匹配所以address参数无法被设置,导致创建的监听能监听其他地址的事件。
9)关于取消nonce导致发布的合约地址不变的问题?
因为在实际项目中对Ethereumj的版本进行调整,取消了nonce的限制,然而该智能合约在发布的过程中会根据发送者的地址和发送者拥有的nonce生成合约地址,所以在发送者地址一致的和nonce一致的情况下,发布的合约的地址都是同一个,新发布的合约会覆盖久的合约,导致程序发布错误。


免费领取验证码、内容安全、短信发送、直播点播体验包及云服务器等套餐

更多网易技术、产品、运营经验分享请点击