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

叁叁肆2018-12-07 15:51

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

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


7.智能合约经验分享
1)智能合约开发的工具的问题
古人云“工欲善其事必先利其器”,同意良好的智能合约的开发工具对智能合约的开发效率有极大的提升。以下是一些比较好的智能合约的开发组合:
  •  Remix+Ganache/testrpc:Remix为合约编辑工具,还能自动生成可视化调用入口;Ganache合约的模拟环境,能可视化查看事物、调用的命令等;testrpc功能和ganache一样,但是不是可视化界面;
  •  Truffle+Ganache/testrpc:Truffle是一款集合约部署、调用、单元测试、调试与一身的智能合约开发工具,功能很强大。
注:Remix有在线网页版和本地网页版,如果嫌在线网页版满可以安装本地网页版;另外本人在安装使用Ganache出现过Remix无法获取其自创建的账户,可以试着重新下载安装。
2)针对web3j开发基于区块链的外部应用时,自定义脚本的使用
以web3j为例(当然不局限于web3j),一般情况下针对sol源文件需要经过solcd的编译合约和生成Java文件两部命令操作。在实际项目中,为了“一键编译生成Java文件”首先编写了自动化脚步,然后通过命令自动编译Java文件到指定目录下,这点对外部系统的智能合约的开发效率上也起到了很大的提升,例如“一键编译生成Java文件”脚步如下:
#!/bin/sh
 
sol_directory="../src/main/resources/solidity"
abi_bin_directory="../src/main/resources/solidity/build"
java_directory="../src/main/java"
java_package="com.netease.blockchainsdk.contracts.generated"
 
for file in ${sol_directory}/*
do
    if test -f $file
    then
     if [ "${file##*.}"x = "sol"x ];then
tmp=${file##*/}
filename=${tmp%.*}
echo  ${filename}
        echo "Compiling Solidity file ${filename}.sol"
        solc --bin --abi --optimize --overwrite \
            --allow-paths "$(pwd)" \
            soldirectory/{filename}.sol -o ${abi_bin_directory}/
        echo "Complete"
        
        echo "Generating contract bindings"
        web3j-3.2.0/bin/web3j solidity generate \
         abibindirectory/{filename}.bin \
         abibindirectory/{filename}.abi \
         -p ${java_package} \
         -o ${java_directory} > /dev/null
     echo "Complete"
fi
    fi
    if test -d $file
    then
        echo $file 是目录
    fi
done
注“脚本”、“工具”对于项目来说都是能提升很高的效率,如果有繁琐重复的工作,就需要考虑能否通过脚本或者工具去解决。
3)针对web3j的Java应用开发的注意点
通过脚本,生成智能合约对应的java类(该java类对智能合约做了封装),java类中提供deploy发布智能合约、load加载智能合约、EventObservable事件监听通知、buyproduct等业务函数。业务逻辑调用生成的智能合约的java类(proxy智能合约类),实现业务逻辑。使用者使用开发JAR包调用合约需要注意的点:
  •  一次监听调用者用封装的ContractProxy.observeEventOnce方法,及时释放监听事件;长期性的事件监听者(如数据服务提供者)则用长期监听事件方法ContractProxy.observeEvent;
  •  事件监听observe接口参数需要指定监听事件的起始块number,建议把最后处理块number存入redis等,每次加载重启时从redis获取;如果一个block中有多个事件需要处理,还得引入订单号等业务逻辑去重;
  •  监听事件在分布式环境中会被多次处理,需要加强协调机制;
  •  监听的回调函数继承CallBackFun类,注意实现onTimeout、onError等逻辑;
  •  RemoteCall可以使用异步发送+超时机制,见ContractProxy的asynCall方法;
  •  调用参数的隐私性通过公钥加密私钥解密手段实现,加解密参考CryptoUtils类,订单号生成参考UIDGenenrator类。
4)在合约中使用自定义互斥锁
使用互斥锁。即让你“锁定”某些状态,后期只能由锁的所有者对这些状态进行更改,如下所示,这是一个简单的例子: 
如果用户在第一次调用结束前尝试再次调用withdraw() 函数,那么这个锁定会阻止这个操作,从而使运行结果不受影响。这可能是一种有效的解决方案,但是当你要同时运行多个合约时,这种方案也会变得很棘手,以下是一个不安全的例子:
这种情况下攻击者可以调用函数getLock()锁定合约,然后不再调用函数releaseLock()解锁合约。如果他们这样做,那么合约将被永久锁定,并且永远不能做出进一步的更改。如果你使用互斥锁来防止竞态条件,你需要确保不会出现这种声明了锁定但永远没有解锁的情况。在编写智能合约时使用互斥锁还有很多其他的潜在风险,例如死锁或活锁。如果你决定采用这种方式,一定要大量阅读关于互斥锁的文献,避免“踩雷”。
5)合约中的错误处理
Solidity提供了两个函数assert和require来进行条件检查,如果条件不满足则抛出异常。assert函数通常用来检查(测试)内部错误,而require函数来检查输入变量或合同状态变量是否满足条件以及验证调用外部合约返回值。另外,如果我们正确使用assert,有一个Solidity分析工具就可以帮我们分析出智能合约中的错误,帮助我们发现合约中有逻辑错误的bug。
除了可以两个函数assert和require来进行条件检查,另外还有两种方式来触发异常:
  •  revert函数可以用来标记错误并回退当前调用;
  •  使用throw关键字抛出异常(从0.4.13版本,throw关键字已被弃用,将来会被淘汰。)
  •  当子调用中发生异常时,异常会自动向上“冒泡”。 不过也有一些例外:send,和底层的函数调用call, delegatecall,callcode,当发生异常时,这些函数返回false。
注意:在一个不存在的地址上调用底层的函数call,delegatecall,callcode 也会返回成功,所以我们在进行调用时,应该总是优先进行函数存在性检查。在下面通过一个示例来         说明如何使用require来检查输入条件,以及assert用于内部错误检查:
 *assert类型异常, 在下述场景中自动产生assert类型的异常:
         1.如果越界,或负的序号值访问数组,如i >= x.length 或 i < 0时访问x[i]
         2.如果序号越界,或负的序号值时访问一个定长的bytesN。
         3.被除数为0,如5/0或 23 % 0。
         4.对一个二进制移动一个负的值。如:5<<i; i为-1时。
         5.整数进行可以显式转换为枚举时,如果将过大值,负值转为枚举类型则抛出异常
         6.如果调用未初始化内部函数类型的变量。
         7.如果调用assert的参数为false
当发生assert类型的异常时,Solidity会执行一个无效操作(指令0xfe)。
 *require类型异常, 在下述场景中自动产生require类型的异常:
         1.调用throw
         2.如果调用require的参数为false
         3.如果你通过消息调用一个函数,但在调用的过程中,并没有正确结束(gas不足,没有匹配到对应的函数,或被调用的函数出现异常)。底层操作如call,send,delegatecall或callcode除外,它们不会抛出异常,但它们会通过返回false来表示失败。
         4.如果在使用new创建一个新合约时出现第3条的原因没有正常完成。
         5.如果调用外部函数调用时,被调用的对象不包含代码。
         6.如果合约没有payable修饰符的public的函数在接收以太币时(包括构造函数,和回退函数)。
         7.如果合约通过一个public的getter函数(public getter funciton)接收以太币。
         8.如果.transfer()执行失败
当发生require类型的异常时,Solidity会执行一个回退操作(指令0xfd)。
在上述的两种情况下,EVM都会撤回所有的状态改变。是因为期望的结果没有发生,就没法继续安全执行。必须保证交易的原子性(一致性,要么全部执行,要么一点改变都没有,不能只改变一部分),所以需要撤销所有操作,让整个交易没有任何影响。
注意assert类型的异常会消耗掉所有的gas, 而require从大都会版本(Metropolis, 即目前主网所在的版本)起不会消耗gas。


8.智能合约的漏洞事件分析
1)数值溢出问题
在编写智能合约时,特别是涉及资金转账的情况下,需要校验转账的数值是否发生溢出。建议可以通过增加safeMath(以太访也提供了)方法,将涉及计算的地方都替换为安全计算方法,规避该问题:
Library SafeMath{
   function mul(uint256 a,uint256 b) internal constant returns(uint256){
   uint256 c=a *b;
    assert(a==0||c/a==b);
    return c;
}
function div(uint256 a,uint256 b) internal constant returns(uint256){
uint256 c=a/b;
return c;
}
function sub(uint256 a,uint256 b) internal constant returns(uint256){
uint256 c=a/b;
return c;
}
function add(uint256 a,uint256 b) internal constant returns(uint256){
uint256 c=a+b;
assert(c>=a);
return c;
}
}
2)Call Deep Attack(栈深度限制)攻击
为了防止在执行智能合约时出现无限递归调用,EVM规定了调用栈深度不能超过1024,一旦超过1024,那么EVM将不再执行该操作。例如以下代码:
contract auction{
 mapping(address=>uint) refunds;
//....
function withDrawReFund(address receipt){
uint refund=refunds[receipt];
refunds[receipt]=0;
receipt.send(refund);
}
}
假如当我们运行send函数时,事实上调用了receipt的回调函数,本质上是调用一个函数,因此黑客利用在执行的send方法时,通过大量的合约调用构造了一个调用深度为1023的栈,那么会导致withdraw方法执行不成功,但是其他方法可以执行。但是这类攻击在EIP155中已经修复。
3)Reentrancy(可重入)攻击
The DAO事件便是由此类攻击引起的,该攻击是黑客重复调用某个函数,如下:
  mapping(address=>uint) private userBalances;
//....
function withDrawBalance() public {
uint amountToWithDraw=userBalances[msg.sender];
If(!(msg.sender.call.value(amountToWithDraw))){throw;}
userBalances[msg.sender]=0;
}
由于用户在执行msg.sneder.call.value方法时,实际调用的是该sender账户的fallback函数,而该sender账户可能是一个合约账户,同时该账户中fallback函数又调用withdrawbalance方法,那么就实现了黑客可以重复调用withdrawBalance。
解决方式:使用函数send()而不是函数call.value()(),这将阻止任何外部代码的执行;但是如果无法避免要调用外部函数时,防止这种攻击的下一个简便方法就是确保在你调用外部函数时已完成所有要执行的内部操作。
4)Cross-function Race Conditions(跨函数的竞态条件)攻击
可重入攻击是通过对同一个函数的不断调用,而cross function race condition则是通过对不同函数的组合调用来实现攻击的一种方法。具体如下:
 mapping(address=>uint) private userBalances;
//....
function transfer(address to,uint amount){
If(userBalances[msg.sender]>=amount){
 userBalances[to]+=amount;
userBalances[msg.sender]-=amount;
}
}
function withDrawBalance() public {
uint amountToWithDraw=userBalances[msg.sender];
If(!(msg.sender.call.value(amountToWithDraw))){throw;}
userBalances[msg.sender]=0;
}
加入我们在调用withdrawBalance的时候,调用者在userBalance还有100ether,那么我们在执行msg.sender.call.value时,假如这个sender是一个智能合约B,并且B在fallback函数中会调用这个合约的transfer方法,而这个时候由于userBalance中的调用者还有100Ether,因此他还是可以进行转账给to,当执行完后,调用者的合约账户才为0,对于调用者来说多转了1倍的钱。
解决方案,这儿有两种解决方案,一是我们建议先完成所有的内部工作,然后再调用外部函数;二是使用互斥锁(即让你“锁定”某些状态,后期只能由锁的所有者对这些状态进行更改)。
5)DOS with(Unexpected) Throw
一般情况是遇到异常情况下,选址throw关键字进行异常抛出,但是某些场景下需要注意对throw的使用。
Contract Auction{
Address currentLeader;
Uint highestBid;
Function bid(){
If(msg.value<=highestBid){throw;}
If(!currentLeader.send(highestBid)){throw;}
currentLeader=msg.sender;
highestBid=msg.value;
}
}
假设当前最高价是100,如果有恶意的竞标者A,竞标价为101,且该账户是一个智能合约账户,并且他的fallback函数是一个无限循环,那么另一个出价102后会出现异常,导致无法执行,无论其他人出多少价,A都是赢家。
6)Dos with Block Gas Limit攻击
由于每个交易和Block都有gaslimit的限制,因此编写合约的时候需要考虑gaslimit的限制,以及超过gaslimit限制时出现的异常问题。
若一个合约中数据量一直增大,导致处理合约数据时一直会超过gaslimit限制,则会导致合约的功能异常。
7)交易顺序依赖与非法预先交易漏洞
交易顺序依赖(Transaction-Ordering Dependence,TOD);
非法预先交易(Front Running)非法预先交易是经纪人从客户交易中获利的一种不道德做法。在手中持有客户交易委托的情况下抢先为自己的账户进行交易。以下是区块链固有的不同类型的竞态条件:在区块内部,交易本身的顺序很容易受到人为操控。
由于在矿工挖矿时,每笔交易都会在内存池中待一段时间,因此可以想象到交易被打包进区块前会发生什么。对于去中心化的市场,可更改的交易顺序会带来很多的麻烦。比如市场上常见的买入某些代币的交易。而防范这一点十分地困难,因为它会涉及到合约中具体的实现细节。例如,在去中心化市场中,由于可以防止高频交易,故批量拍卖的效果更好。另一种解决方法就是采用预先提交方案的机制,别着急,后面我会详细介绍这个机制的细节。
8)DNS挟持攻击
解决方式:一定要确保域名以及 HTTPS 证书是正确的;在联网情况下,不要输入私钥,选择选择合适的钱包。
9)强行给智能合约中加入以太币
原则上,我们可以将以太币强制发送到智能合约中而不触发回退函数。当给回退函数加入重要功能或计算智能合约的收支平衡时,这是一个重要的考虑因素。请看下面这个例子:
10)已废弃协议攻击
这些攻击由于以太坊协议的改变或以太坊编程语言solidity的改进而不能使用。
11)Timestamp depedence(时间戳依赖)攻击
合约中的时间都是根据Block的时间戳获得的,因此如果矿工改变了块的时间戳将引发程序执行逻辑不一样。
Uint startTime=x;
If(now>startTime +1 week){//do...}


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

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