智能合约是以太坊的精髓,也是以太坊和比特币一个最大的区别。
智能合约的本质是运行在区块链上的一段代码,代码的逻辑定义了智能合约的内容
智能合约的账户保存了合约当前的运行状态
balance:当前余额
nonce:交易次数
code:合约代码
storage:存储,数据结构是一颗MPT
Solidity是智能合约最常用的语言,语法上与JavaScript很接近
Solidity是面向对象的变成语言,这里的contract类似于c++当中的类class,这里的contract定义了很多状态变量,Solidity是强类型语言,这里的类型跟普通的编程语言像c++之类的是比较接近的,比如说这个uint,unsigned int是无符号的整数,这个address类型是Solidity语言所特有的。
接下来是两个event事件,作用是用来记录日志的。
第一个事件是HighestBidIncreased,拍卖的最高出价增加了,这个代码的例子是一个网上拍卖的例子,如果有人出现新的最高价,记录一下参数是address bidder,金额是amount,第二个事件是Pay2Beneficiary,参数是赢得拍卖的人的地址以及他最后的出价amount。
Solidity语言跟别的普通编程语言相比有一些特别之处。
比如说mapping,mapping是一个哈希表,保存了从地址到unit的一个映射,首先这个Solidity语言中哈希表比较奇怪的地方是它不支持遍历,如果想遍历哈希表里的所有元素,需要自己想办法记录哈希表中有哪些元素,这里是用bidders数组来记录的,Solidity语言中的数组可以是固定长度的,也可以是动态改变长度的,这里是一个动态改变长度的数组,比如说你要想在数组里增加一个元素,就用push操作,bidders.push(bidder),新增加一个出价人在数组的末尾,要想知道这个数组有多少个元素,可以用bidders.length,如果是固定长度的数组的话,就要写明数组的长度,比如说address[1024],这个就是长度为1024的数组。
再往下是他的构造函数,Solidity语言中定义构造函数有两种方法。
构造函数只能有一个。
一种方法就是像c++构造函数一样,定一个与contract同名的函数,这个函数可以有参数,但是不能有返回值。
实际上新版本Solidity语言更推荐用本例子的方法,就用一个constructor来定义一个构造函数,这个构造函数只有在合约创建的时候会被调用一次。
接下来是三个成员函数,三个函数都是public,说明其他账户可以调用这些函数,注意bid这个函数,这里标志有一个payable,这个后面会解释下是什么意思。
调用智能合约其实跟转账是类似的,比如说A发起一个交易转账给B。
如果B是一个普通的账户,那么这就是一个普通的转账交易,就跟比特币当中的转账交易时一样的。
如果B是一个合约账户的话,那么这个转账实际上是发起一次对B这个合约的调用,那么具体是调用合约中的哪个函数呢,是在数据域data域说明的
这个例子当红有三个address,是发起这个账户调用的例子,to contract address是被调用的合约的例子,调用的函数就是txdata,如果函数是有参数的话,那么参数的取值也是在data域里说明的,上一页看的网上拍卖的样例当中,三个成员函数都没有参数,但是有的成员函数是可以有参数的。
中间那一行是调用的参数,VALUE是说发起调用的时候转过去多少钱,这里是0,就我这个调用的目的仅仅是为了调用他的函数,并不是真的要转帐,所以VALUE=0,gas used是我这个交易花了多少汽油费,gas price是单位汽油的价格,gas limit是我这个交易最多原意支付多少汽油费。
直接调用
有A,B两个合约,A这个合约就只是写成log,event定义事件LogCallFoo,emit LogCallFoo(),就用emit这个操作来调用这个事件,emit语句的作用就是写一个log,对于程序的运行逻辑是没有影响的。B这个合约,这个函数参数是一个地址,就是A这个合约的地址,然后就这个语句把这个地址转换成A这个合约的一个实例,然后调用其中的foo这个函数。
说明一点:以太坊中规定一个交易只有外部账户才能够发起,合约账户不能自己主动发起一个交易
所以这个例子当中需要有一个外部账户调用了合约B当中的这个callAFooDirectly函数,然后这个函数再调用合约A当中的foo函数
使用地址类型
第一个参数是要调用函数的签名,然后后面跟的是调用的参数,这种调用的方法跟上一个调用的方法相比,一个区别是对于错误处理的不同,上一种方法,如果你调用了那个合约在执行过程中出现错误,那么会导致发起调用的这个合约也跟着一起回滚,如果在上个例子中A在执行过程出现什么异常,会导致B这个合约也跟着一起出错。
而这种address.call()这种形式如果在调用过程中,被调用的合约抛出异常,那么这个call函数会返回false,表明这个调用是失败的,但是发起调用的这个函数并不会抛出异常,而是可以继续执行。
代理调用 delegatecall()
这个和刚才讲的call这种方法基本上是一样的,一个主要的区别是delegatecall不需要切换到被调用的合约的环境中去执行,而是在当前合约环境中执行就可以了,比如就用当前账户的账户余额存储之类的
再看一下刚才讲过的代码结构
这个bid函数,这里有一个payable,另外两个函数都没有,以太坊中规定如果这个合约账户要能接收外部转账的话,那么必须标注成payable,那么这个例子中bid函数是什么意思?这是一个网上拍卖的合约,bid函数是用来进行竞拍出价的,比如说你要参与拍卖,你说你出100个以太币,那么就调用合约当中的bid函数,所以拍卖规则是,调用bid函数时要把拍卖的出价100个以太币也发送过去,存储到这个合约里,锁定到拍卖结束,避免有人凭空出价,你说你出1万个以太币,实际上你没那么多钱,所以你要拍卖的时候,要把你发的价钱放到合约里锁定起来,所以bid函数要有能够接收外部转账的能力,所以才标注一个payable。第二个withdraw函数就没有payable,withdraw就是拍卖结束了,出价最高的那个人赢得了拍卖,其他人没有拍到想要的东西,可以调用withdraw把自己当初出的价钱,就是原来bid的时候锁定在智能合约里的以太币再取回来,因为这个的目的不是为了真的转账,不是要把钱转给智能合约,而仅仅是调用withdraw函数把当初锁定在智能合约里的那一部分钱取回来,所以没必要弄payable
这是我们刚才看的例子,转账交易的例子,这个时候这个交易就是零,这个交易就属于并没有真的把钱转出去,所以这个TO CONTARACT ADDRESS就不用定义成payable,以太坊中凡是要接收外部转账的函数,都必须标识为payable,否则你给这个函数转出钱的话,会引发错误处理,会抛出异常,如果你不需要外部转账你就不用标识为payable。
最后有一个特殊的函数叫fallback()函数
这个函数既没有参数也没有返回值,而且也没有函数名是个匿名函数,这个fallback关键字也没有出现在这个函数名里,前面说调用这个合约的时候,A调用B这个合约,然后要在转账交易的data域说明你调用的是B当中的哪个函数,如果A给合约B转账了一笔钱,没有说明调用的是哪个函数,他的data域是空的,那怎么办呢?那么这个时候缺省的就是调用这个fallback()函数,为什么叫fallback()函数,因为没有别的函数可调了,就调他。还有一种情况是你要调的函数不存在,在那个data域里,你说要调这个函数,而实际这个合约当中没有这个函数,那怎么办呢?也是调用这个fallback()函数,这就是为什么这个函数没有参数也没有返回值,因为他没法提供参数。对于fallback()函数来说,也可能需要标注payable关键字,如果fallback()函数需要有接收转账的能力的话,也需要写成是payable,一般情况下,都是写上payable的,如果合约账户没有任何函数标识为payable,包括fallback()函数函数也没有标识成payable,那么这个合约没有任何能力接受外部的转账。就是如果这个合约没有fallback()函数或者是有fallback()函数 但是没有写payable,那么其他人往这个合约里转一笔钱,别的都不说,data域是空的就会引发异常。
fallback()函数和payable都是在合约定义的时候写的,我给你转账时候不用写payable,也不用写fallback(),就是说我如果给你转账,别的什么都不写,没有调用你其中的任何一个函数,那么就自动调用这个fallback()函数,fallback()函数不是必须定义的,就是你这个合约可以没有fallback()函数,如果没有fallback()函数的话,出现前面说的几种情况,就会抛出异常。就比如我给一个合约转账,没有说调哪个函数,那个合约也没有定义fallback()函数,那么这个转账就是错误的,就会引发错误处理。另外只有合约账户才有这些东西,外部账户跟这个都没有关系,外部账户都没有代码,还有一点,转账金额可以是0,但是汽油费是要给的,这是两码事,转账金额是给收款人的,汽油费是给发布这个区块的矿工的,如果汽油费不给的话,矿工不会把你这个交易打包发布到区块链
到目前为止,讲的都是智能合约的调用,讲了两种调用的方法。
那只智能合约是怎么创建的呢?
是由一个外部账户发起一个转账交易,转给0x0这个地址,然后把这个要发布合约的代码放到data域里面。你要创建一个合约,要发起一个转账交易,给0这个地址转账,转账的金额都是0,因为你实际上不是真的想转帐,只是想发布一个智能合约,发布的这个智能合约的代码放到数据域就行了,合约的代码写完之后都是要编译成bytecode,然后运行在EVM上。听说过JVM吧,Java Virtual Machine,JVM的目的是什么?为什么要加一个JVM,增强可移植性,EVM也是类似的设计思想,通过加一层虚拟机,对智能合约的运行提供一个一致性的平台,所以EVM有时叫做Worldwide Computer,全世界的一个计算机,EVM的寻址空间是非常大的256位,像前面讲的unsigned int就是256位。
如果比较一下比特币和以太坊,这两种区块链的编程模型,设计理念是有很大差别的,比特币设计理念是简单,脚本语言的功能很有限,比如说不支持循环,而以太坊是要提供一个图灵完备的编程模型,Turing-complete Programming Model,很多功能在比特币平台上实现起来很困难,甚至是根本实现不了,而到以太坊平台上呢,实现起来就很容易,当然,这样也带来一个问题,出现死循环怎么办,当一个全节点收到一个对智能合约的调用,怎么知道这个调用执行起来会不会导致死循环。
有什么办法吗?
没有办法,这实际上是一个Halting Problem停机问题,停机问题是不可解的,注意一点,这里的问题不是NPC的,NPC的问题是可解的,但是没有多项式时间的解法,很多NPC问题有很自然的指数级的解法,比如说哈密尔顿回路问题,判断一个图有没有哈密尔顿回路,这个其实是很容易解的,想要一个解法是很容易的,如果不考虑复杂度的话,你觉得怎么解?把所有的可能性枚举一遍,比如说你有n个顶点,n个顶点的排列是n个阶层这么多个组合,把每个组合检查一下是不是构成一个合法的回路,就知道他有没有哈密尔顿回路,所以哈密尔顿回路问题是可解的,只不过解的复杂度是指数级的。而停机问题是根本就不可解的,从理论上可以证明不存在这样一个算法,能够对任意给定的输入程序判断出这个程序是否会停机,这是不可解的。那怎么办呢?办法就是把这个问题推给发起交易的那个账户,以太坊引入了汽油费机制,发起一个对智能合约的调用要支付相应的汽油费
看一下这是一个交易的数据结构,AccountNonce就是这个交易的序号,用于防止前面说的replay attack,Price和GasLimit就是跟汽油费相关的,GasLimit是这个交易原意支付的最大汽油量,Price是单位汽油的价格,两个乘在一起就是这个交易可能消耗的最大汽油费,Recipient就是收款人的地址,转账交易转给谁的收款人地址,Amount是转账金额,把Amount这么多钱转给Recipient,也可以看到交易当中的汽油费跟转账金额是分开的,Payload就是前面说的data域,用于存放调用的是合约中的哪一个函数,函数的参数取值是什么,都在Payload里面。
当一个全节点收到一个对智能合约的调用的时候,先按照调用过程中给出的GasLimit算出可能花掉的最大汽油费,然后一次性的把这个汽油费从这个发起调用的账户上扣掉,然后再根据实际执行的情况,算出实际花了多少钱,多退少补,其实不叫多退少补,如果汽油费不够的会引起回滚,就一次性先把汽油费先扣掉。不同的指令消耗的汽油费是不一样的,一些简单的指令,比如说加法减法消耗的汽油费是很少的,复杂的指令消耗的汽油费就比较多,比如说取哈希,这个运算,一条指令就可以完成,但是汽油费就比较贵,除了计算量之外,需要存储状态的指令,消耗的汽油费也是比较大的,那么相比之下,如果你仅仅是为了读取公共数据,那么那些指令可以是免费的。
以太坊中的交易执行起来具有原子性,一个交易要么全部执行,要么完全不执行,不会只执行一部分,这个交易既包含普通的转账交易,也包含对智能合约的调用,所以如果在执行智能合约的过程当中,出现任何错误,会导致整个交易的执行回滚,退回到开始执行的之前的状态,就好像这个交易完全没有执行过,那么什么情况下会出现错误呢。
一种情况就是刚才说的汽油费,如果这个交易执行完之后,没有达到当初的GasLimit,那么多余的汽油费会被退回到这个账户里,一开始的时候是按照最大的GasLimit把汽油费扣掉了,如果最后运行完了,还有剩下来的,实际上是用的多少汽油收多少钱,剩的可以退回去,相反,如果执行到一半,这个GasLimit已经都用完了,那么这个时候这个合约的执行要退回到开始执行之前的状态,这又是一种错误处理,而且这个时候已经消耗掉的汽油费是不退的,为什么要这么设计呢,执行的状态要回滚,但已经耗掉的汽油费是不退的,因为要么的话就会有恶意的节点可能会发动delayous service attack,可能他发布一个计算量很大的合约,然后不停的调这个合约,每次调的时候给的汽油费都不够,反正最后汽油费还会退回来,那么对我来说没有什么损失,但是对矿工来说是白白浪费了很多的资源,这就是为什么说,汽油费不够的话,执行到一半会回滚,花掉的汽油费是不退的。
除了这种汽油费不够的情况,还有一种情况是引起错误处理的,比如说assert语句和require语句,这两个语句都是用来判断某种条件,如果条件不满足的话,就会导致抛出异常,assert语句一般来说是用于判断某种内部条件,有点像c语言中的assert是一样的,require语句一般用于判断某种外部条件,比如说判断函数的输入是否符合要求,那么这里就给了一个简单的例子,这个bid简单的函数,这个竞拍的函数判断一下,当前的时间now<=拍卖的结束时间auctionEnd,如果符合条件继续执行,如果不符合的话,拍卖都已经结束了,你还在出价,这个时候就会抛出异常,那么第三个语句是这个revert,这个revert是无条件的抛出异常,如果执行到revert语句,那么自动的就会导致回滚,早期的版本里用的是throw语句,新版本solidity里建议改用revert这个语句,最后一点值得注意,solidity当中没有这种try-catch这种结构,有的编程语言像Java,用户自己可以定义出现问题后怎么办,他有这种try-catch,solidity里没有这种结构
前面说智能合约出现错误会导致回滚,那么如果是嵌套调用,一个智能合约调用另外一个智能合约,那么被调用的这个智能合约出现错误,是不是会导致发起调用的智能合约,也跟着一起回滚呢,这个所谓的叫连锁式回滚,这个呢不一定,这个取决于调用这个智能合约的方式,比如我们前面讲过两种方式,是直接调用的话,会出现连锁式的回滚,整个交易都会回滚,如果调用的方式是用比如说call这种方式,他就不会引起连锁式回滚,只会使当前的调用失败返回一个false的返回值,最后注意一点,有些情况下,从表面上看你并没有调用任何一个函数,比如说,你就是往一个账户里转账,但是这个账户是合约账户的话,转账这个操作b本身就有可能触发对函数的调用,因为有fallback()函数,这就是一种嵌套调用,一个合约往另一个合约里转账,就有可能调用这个合约里的fallback函数。
这是当初看的Block Header的数据结构
这个地方有一个GasLimit和GasUsed,这两个也是跟汽油费相关的,Block Header里面的GasUsed是这个区块里所有交易所消耗的汽油费加在一起,GasLimit其实不是这样的,发布区块需要消耗一定的资源,这个消耗的资源要不要有一个限制,比特币当中对于发布的的区块也是有一个限制的,大小的限制,最多不能超过一兆,因为发布的区块如果没有任何限制,有的矿工可能把特别多的交易全部打包到一个区块里面然后发布出去,那么这个超大的区块在区块链上会消耗很多资源,所以它规定每个区块最多不能超过一兆,比特币交易是比较简单的,基本上可以用交易的字节数来衡量出这个交易消耗的资源有多少,但以太坊中如果这么规定是不行的,因为以太坊中智能合约的逻辑很复杂,有的交易可能从字节数上看是很小的,但他消耗的资源可能很大,比如他可能调用别的合约之类的,所以要根据交易的具体操作来收费,这就是汽油费,所以这个GasLimit是什么意思呢?他是这个区块里所有交易能够消耗的汽油的一个上限,不是说把区块里每个交易的GasLimit加在一起,如果那样的话,就等于没有限制了,因为每个交易的GasLimit是发布这个交易的账户自己定的,定多少是自己说了算,但是这个区块中的所有交易,实际能够消耗的汽油是有一个上限的,不能无限的消耗,否则你也可能发布一个,对资源消耗很大的一个区块出去,对整个系统的运行是没有好处的。
GasLimit跟比特币的区别:比特币限制资源是按照大小来限制的,而且这个1M的上限是固定了的,是写死在协议里面的,有些人认为1M太小了,而且有的分叉币的产生就是为了提高这个上限,以太坊中也有一个上限,这个GasLimit,但是每个矿工在发布区块的时候可以对GasLimit进行微调,可以在上一个GasLimit的基础上上调或者下调1/1024,如果出现像比特币那种情况,大家都觉得这个GasLimit不行,设的太小了,协议写的不好,那轮到你发布区块的时候可以增加1/1024,大家不要觉得好像这个比例很小,1/1024听起来很小,以太坊的出块速度很快,十几秒就是一个新的区块,所以的话,如果大家都觉得当前的GasLimit太小,那么很快就可以翻一番,当然,也可能下调,有矿工认为GasLimit太大了需要下调,所以这种机制实际上求出的GasLimit,是所有矿工认为比较合理的GasLimit的一个平均值,有的矿工认为要上调,有的矿工认为要下调,那么每个矿工在获得记账权之后就按照自己的意愿进行这种上调或者下调的微调,所以最后整个系统的GasLimit就趋向于所有矿工的一个平均意见。
下面说这个Receipt数据结构
说之前,有一个问题:
假设某个全节点要打包一些交易到一个区块里面,这些交易里有一些是对智能合约的调用,那么这个全节点应该先把这个智能合约都执行完之后再去挖矿呢,还是说先挖矿获得了记账权然后再执行这些智能合约?
区块链里有一笔转账交易发布上去的话,本来就是需要所有的全节点都执行的,这个不是一种浪费也不是一种出问题了,就是所有的全节点要同步状态,大家都要在本地执行这个转账交易,如果一个全节点不执行那就出问题了,那他的状态跟别人的状态是不一样的,比特币也是一样的,比特币发布一个交易到区块链上,也是要所有的全节点都得执行这个转账交易,要不然怎么更新UTXO啊,先往回退一步,不回答这个问题,我们说在全节点收到一个对合约的调用的时候,要一次性的先把这个调用,可能花掉的最大汽油费从发起这个调用的账户上扣掉,这个具体是怎么操作的,一个全节点可能用于验证交易,后面可能跟了一大堆矿工,光是进行挖矿,比特币中有些矿工光是算哈希值,就有一个ASIC矿机不停的算哈希值,没有全节点的其他功能,像验证交易,监听交易,验证发布的区块,他可能都是outsource给那个全节点做,我们说的时候不是很区分这两个概念,全节点和矿工。这个汽油费是怎么扣的?还记得三棵树吗,状态树,交易树和收据树,这个实际上是收据树的结构,这三棵树都是全节点在本地维护的数据结构,状态树记录了每个账户的状态包括账户余额,所以扣汽油费的时候实际怎么扣的?全节点收到调用的时候,从本地维护的数据结构里把账户的余额减掉就行了,如果余额不够的话,这个交易就不能执行,一次性要按GasLimit把他这个余额减掉,如果没有这么多钱就不执行,执行完之后如果有剩的,再把他的余额再加回去一点。智能合约执行过程中任何对状态的修改都是在改本地的数据结构,只有在合约执行完了,而且发布到区块链上之后,本地的修改才会变成外部可见的,才会变成区块链上的共识。我们有很多全节点,每个全节点都在本地做这个事情,执行的智能合约可能不完全一样,因为根据你收到的交易可能执行不完全一样,如果某个全节点发布一个区块,我收到这个区块之后,我本地执行的就扔掉了,我要到这个区块里的交易再执行一遍,更新我本地的三棵树。如果我本来已经执行一遍了,我没有挖到矿,那个人发过来我又得执行一遍,我得执行两遍多浪费啊,问题是你不这样还能怎么办,你那个区块里的交易本地那个候选区块中包含的交易跟他发布的那个交易不一定完全一样啊,至少有一个肯定不一样,给出块奖励的那个肯定不一样,他不会给你,别的交易也不一定就一样,所以这个没有办法,都是得要重新执行一遍。
以太坊挖矿其实也是尝试各种nonce找到一个符合要求的,计算哈希的时候要用到什么?要用到这个Block Header的内容对不对,Block Header的内容这三项是干嘛来着,这个Root,TxHash,ReceiptHash,是那三棵树的根哈希值,所以得先干嘛?
你得先执行完这个区块中的所有交易包括智能合约的交易,这样才能更新这三棵树,这样才能知道这三个根哈希值,这样这个Block Header的内容才能确定然后才能尝试各个nonce。那这样带来一个问题哦,假设我是一个矿工我费了半天劲执行这些智能合约,消耗了我本地的好多资源,最后我挖矿没挖到怎么办,因为挖矿是竞争对吧,很多矿工竞争,记账权被别人抢先了,那我能得到啥补偿,我能得到汽油费吗?汽油费是没有的,因为汽油费是给那些获得记账权发布区块的那个矿工,那我能得到啥补偿,以太坊中没有任何补偿,他得不到汽油费也得不到任何补偿,不仅如此,他还要把别人发布的区块里的交易在本地执行一遍,以太坊中规定要验证发布区块的正确性,每个全节点要独立验证,那怎么验证呢?别人发布一个交易区块,你把那个区块里的所有交易执行完一遍,更新三棵树的内容,算出根哈希值,再跟他发布的那个根哈希值比较一下看是不是一致,所有这些都是免费的,没有人给你补偿,所以呢,这种机制下,挖矿慢的矿工就特别吃亏,本来汽油费的设置的目的是对于矿工执行这些智能合约所消耗的这些资源的一种补偿,但是这种补偿只有挖到矿的矿工才能得到,其他的矿工等于是陪太子读书。
下一个问题:
会不会有的矿工你不给我汽油费,那我就不验证?比如说我挖半天没有挖到矿,你发布一个区块,按照协议我要验证一下你这个区块的正确性,我验证他有啥好处,你又不给我汽油费,我验证他干嘛,我就认为你是正确的不就行了吗,我就接着挖,会不会有矿工想不通?
先说一下,如果这样做会导致什么后果,最直接的后果是危害区块链的安全,区块链的安全是是怎么保证的,就是要求所有的全节点要独立验证发布的区块的合法性,这样少数有恶意的节点没法篡改区块链上的内容,如果某个矿工想不通,不给钱我就不验证了,这样的风气蔓延开来就会危及区块链的安全,会不会有这样的情况?如果他跳过验证这个步骤,他以后就没法再挖矿了,因为你验证的时候是要把区块的交易再执行一遍,更新本地的那三棵树,如果不去验证的话,本地三棵树的内容没有办法更新,以后再发布区块你怎么发布,你本地的这些状态就不对了,你算出的根哈希值发布出去之后别人认为是错的。没有办法跳过验证这个步骤,为什么要执行才能更新状态,因为发布的区块里没有这三棵树的内容,只是块头里有了根哈希值,这三棵树的账户状态具体是什么余额啊,什么东西,发布出来是没有的,讲状态树的时候讲过,不能把状态树的整个状态发布到区块链上,那太多了,而且很多是重复的,状态都不改了,所以不会跳过验证这个步骤,以太坊的安全还是有保证的。
下一个问题:
发布到区块链上的交易是不是都是成功执行的?
如果智能合约执行过程中出现了错误,要不要也发布到区块链上去。执行发生错误的交易也要发布到区块链上去,否则汽油费扣不掉,光是在本地的数据结构上把他的账户扣了汽油费,是没用的,你拿不到钱,你得把区块发不上去之后形成共识扣掉的汽油费才能成为你账户上的钱,所以发布到区块链上的交易不一定都是成功执行的,而且要告诉大家为什么扣汽油费,而且别人得验证一遍,也要把这个交易执行完一遍,看你扣的是不是对的。
那怎么知道一个交易是不是执行成功了呢,前面说过那三棵树,每个交易执行完后形成一个收据,这个是这个收据的内容,Status这个域就是告诉你交易执行的情况是怎么样的。
再问一个问题,智能合约是不是支持多线程,现在多核处理器很普遍,一个计算器有十几核,几十个核,都是正常的,那么智能合约支不支持多核并行处理?
Solidity不支持多线程,他根本没有支持多线程的语句,原因是以太坊是一个交易驱动的状态机,这个状态机必须是完全确定性的,就给定一个智能合约。面对同一组输入,产生的输出或者说转移到的下一个状态必须是完全确定的,为什么要求这个,因为所有的全节点都得执行同一组操作到达同一个状态,要验证,如果状态不确定的话,那三棵树得根哈希值根本对不上,必须完全确定才行,多线程得问题在于什么?多个核对内存访问顺序不同的话,执行结果有可能是不确定的,大家感兴趣的话,可以看看北京大学肖臻老师的论文,在http://zhenxiao.com/papers/目录下,以前我们研究过的就是在多核环境下怎么样实现确定性重演,这是一个难度很大的课题,除了多线程之外,其他可能造成执行结果不确定的操作也都不支持,有没有最直接最简单的会导致执行结果不确定的操作,产生随机数,这个操作就是不确定性的,而且这个操作必须得是不确定的,如果不同的机器产生的随机数不一样那不叫随机数了,所以以太坊的智能合约没有办法产生真正意义下的随机数,他可以用一些伪随机数,不能是真的随机数,否则的话,又会出现前面的问题,每个全节点执行完一遍得到的结果都不一样。
智能合约的执行必须是确定性的,这也就导致了智能合约不能像通用的编程语言那样通过系统调用来得到一些环境信息,因为每个全节点的执行环境不是完全一样的,所以他只有通过一些固定的一些变量的值能够得到一些状态信息,这个表格就是智能合约能够得到的区块链的一些信息。
下面是智能合约可以获得的调用信息。
像msg.sender就是发起这个调用的人是谁,注意这个跟最后一个tx.origin交易的发行者是不一样的,比如说我们有一个外部账户A调用了一个合约叫C1,C1当中有一个函数f1,f1又调用另外一个合约C2,里面的函数f2,那么对这个f2函数来说,msg.sender是C1这个合约,因为当前这个msg call,这个调用,是C1这个合约发起的,但是tx.origin是A这个账户,因为整个交易的发起者是A这个账户。
msg.gas就是当前调用还剩下多少汽油费,这个决定了我还能做哪些操作,包括你还想调用别的合约前提是还有足够的汽油费剩下来,msg.data就是所谓的叫数据域,在里面写了调用哪些函数和这些函数的参数取值,msg.sig是msg.data的前四个字节,就是函数标志符调用的是哪个函数,now是当前区块的时间戳,这个跟上一页这个block.timestamp是一个意思,就是智能合约里没有办法获得很精确的时间,只能获得跟当前区块信息的一些时间
第一个是个成员变量,剩下的都是成员函数,成员变量就是账户的余额balance,注意这个unit256,这是这个成员变量的类型,不是函数调用,不是说这个是个参数,这个参数是unit256,不是的,是这个成员变量本身的unit256,是以Wei为单位的,是个很小的单位,所以账户余额看上去都很吓人,看上去每个人都很有钱其实没多少钱,下面这些成员函数的话,有一点要注意的,这一些成员函数的语义跟我们直观上的理解不是很一样,跟第一个成员变量balance也不太一样,这个addr.balance是address这个地址上他的账户他的余额,那addr.transfer(12345)是什么意思呢?感觉像是addr这个账户往外转了12345个Wei,是不是这个意思?如果是这个意思的话,问题在于他只有一个参数,他只有转账的金额,没有说转给谁,所以addr.transfer(unit amount)是什么意思呢?并不是说addr这个账户往外转了多少钱,而是当前这个合约往addr这个地址里转入多少钱,这个addr是转入的地址不是转出的地址,转出的地址是哪一个?比如说这是个智能合约C,里面有一个函数f,它包含这条语句addr.transfer(12345)意思是说C这个合约的账上往这个addr地址里转入12345这么多的钱,addr.call讲过,调用函数,这个其实也是一样的语句,并不是谁addr这个合约账户发起了一个调用,调哪个别的合约账户,而是说当前这个合约发起一个调用,调得是addr这个合约。
delegatecall区别就是说不需要切换到被调用的函数的环境中,就用当前合约的余额,当前合约的存储这些状态去运行就可以了。
问题:
我向一个帐户转账说这个账户没有定fallback函数会引起错误,会不会连锁回滚?
这取决于你怎么转账的,转账有三种方法,这三种形式都可以发送ETH
区别是这个transfer和send,这两个是专门为了转账的函数,区别在于transfer会导致连锁性回滚,类似于你直接调用那个函数直接调用的方法是一样的,失败的时候抛出异常,而send返回一个false,不会导致连锁式回滚,call其实也是可以转账的,call.value(unit256 amount)(),amount金额,后面如果不用调用函数可以是空的,区别在于transfer和send是专门用来转账的,call的话,本意是发动函数调用,但是也可以用来转账,这个也不会引起连锁式回滚,失败时返回false,另外一个区别是transfer和send在发起调用的时候,是给了一点儿的汽油是2300个单位,非常少的,那么收到这个转账的合约基本上干不了别的事,写一个log就行了,别的事都干不了,而call是把当前这个调用剩下的所有的汽油都发过去,比如说call所在的合约本身被调用的时候,可能还剩8000个汽油,然后去调别的合约的时候如果是用call这种方法去转账,就把剩多少汽油都发过去了。
回到一开始讲的这个例子,这个拍卖的例子
拍卖的规则:
拍卖有一个受益人beneficiary,比如说你有一个古董要拍卖,那么这个受益人就是你;auctionEnd事整个拍卖的结束时间;highestBidder是最高出价人,拍卖的规则是这样的:在拍卖结束之前,每个人都可以去出价,去竞拍,竞拍的时候为了保证诚信,要把竞拍的价格的以太币发过去,比如你出价出100个以太币,那么你竞拍的时候要把100个以太币发到这个智能合约里,它就会锁在这里面知道拍卖结束,拍卖的规则不允许中途退出,我去竞拍发了100个以太币,过一会儿我后悔了想把钱要回来,这个不行,拍卖结束的时侯出价最高的那个人highestBidder,他投出去的钱会给这个受益人beneficiary,当然你也要想办法把这个古董给最高出价人,其他没有拍卖成功的人可以把当初投进去的钱再取回来,竞拍是可以多次出价的,比如说我出个价钱,100个以太币,然后呢,另外一个人出价110个以太币,我再出价120个以太币,这个时候我只要补差价就行了,就把我这一次的出价跟上一次的出价差额发到智能合约里,我上次投标的时候已经发了100个以太币,这次只要再发20个以太币就行了,出价要有效的话,必须比最高出价还要高,比如说当前的最高出价是100个以太币,我去竞拍,我投80个以太币,这个是无效的,等于是非法的拍卖。
然后就是我们说的那两个事件constructor会记录下收益人是谁,结束时间是什么时候,这个构造函数,在合约创建的时候,把这两个就记下来了
这个是拍卖用的两个函数,左边的bid函数是竞拍作用的,你要竞拍你就发起一个交易调用这个拍卖中合约的bid函数,这个bid函数有一个奇怪的地方,他没有参数,感觉上你竞拍的时候你不需要告诉对方你出的价格是多少吗?他其实是在msg.value这个地方写的,这个是发起调用的时候,转账转过去的以太币数目,以Wei为单位的转账金额,这个的逻辑是:
首先查一下当前的拍卖还没有结束,如果拍卖结束了,你还出价会抛出异常,然后查一下你上一次的出价加上你当前发过去的以太币大于最高出价,如果你以前没有出价过会怎么样?这个bids是个哈希表,Solidity中哈希表的特点是,如果你要查询的那个键值不存在,那么他返回默认值就是0,所以如果没有出过价,第一部分就是0,然后呢,第一次拍卖的时候把拍卖者的信息放到bidders数组里,原因就是Solidity哈希表不支持遍历,要遍历哈希表的话,要保存一下它包含哪些元素,然后记录一下新的最高出价人是谁,写一些日志之类的。
右边是拍卖结束的函数
首先查一下拍卖是不是已经结束了,如果拍卖还没有结束,有人调用这个函数,就是非法的会抛出异常,然后这个是判断一下这个函数是不是已经被调过了,如果已经被调过了,就不用再调一遍了,首先把这个金额给这个beneficiary,beneficiary.transfer是当前这个合约把这个金额给这个beneficiary转过去,最高出价人的钱是给受益人了,然后哪些剩下的没有竞拍成功的用一个循环,把这个金额退回给这个bidder,然后标明一下,这个函数已经执行完了写一个log。
智能合约是怎么工作的?
你写完一个智能合约,你写一个拍卖程序要先把它发布到区块链上,往那个0地址发一笔转账交易,转账的金额是0,然后把智能合约的代码放到data域里面,汽油费是要交的,然后矿工把这个智能合约发布到区块链上之后会返回这个合约的地址,然后这个合约就在区块链上了,所有人都可以调用他。
每次竞拍存在哪?
智能合约本身有一个合约账户,里面有一个状态信息,他的存储都是在一个MPT存着的。
拍卖的流程:
比如你的外部账户要拍卖,你要发起一个交易,这个交易要调用这个bid函数,然后这个交易要调用这个bid函数要矿工写到区块链里。任何一个人出价参与这个竞拍,调用这个bid函数的操作都需要发布到区块链里。你要竞拍就是写一个Solidity程序,然后你发布一个交易把这个合约放到网上,那别人怎么知道你这个合约,你得线下宣传,用别的方法宣传,区块链不负责给你做这个宣传,就像你的比特币地址别人怎么能知道,你自己去宣传。
上面智能合约那么写是有问题的
写智能合约一定要小心因为智能合约是不可篡改的,说的好听点儿不可篡改,说的不好听点儿叫做你没法改bug,你有bug没法改。
auctionEnd这个函数必须要某个人调用才能执行,这个也是Solidity语言跟其他编程语言不同的一个地方,就是没有办法把它设置成拍卖结束了自动执行auctionEnd,可能是拍卖的受益人beneficiary去调用这个auctionEnd,也可能是参与竞拍没有成功的人去调用,总之得有一个人去调用,如果两个人都去调用auctionEnd,矿工在执行的时候把第一个调用执行完了,然后第二个再执行就执行不了了,因为第一个执行完之后,ended就是true了,没有并发执行。
接下来揭晓答案:
假设有一个人通过这样的一个合约账户参与竞拍,会有什么结果?
这个合约实际上就一个函数hack_bid,这个函数的参数是拍卖合约的地址,然后把它转成这个拍卖合约的一个实例,然后调用拍卖合约用的bid函数,把这个钱发送过去,这实际上是一个合约账户,合约账户不能自己发起交易,所以实际上得有一个黑客从他自己的外部账户发起一个交易,调用这份合约账户的hack_bid函数,然后这个函数再去调用拍卖合约的bid函数,把他自己收到的转过来的钱,这个黑客外部账户转过来的钱再转给这个拍卖合约中的bid函数,就参与拍卖了
有问题吗?好像还是没有问题,我们看一下上一页的函数,这个auctionEnd,这个合约参与拍卖没有问题,最后拍卖结束退款的时候会有什么问题?
这个红框里循环退款,退到合约账户上的钱会有什么情况,退到黑客合约账户上的钱会有什么情况?
黑客外部账户对这个合约来说是不可见的,拍卖合约能看到的只是这个黑客的合约,转账的时候没有调用任何函数,那么当一个合约账户收到转账没有调用任何函数的时候应该调用fallback函数,而这个合约没有定义fallback函数,所以会调用失败,会抛出异常,这个transfer函数会引起连锁式的回滚,就会导致这个转账操作是失败的,收不到钱了,再具体点,比如有29个人参与竞拍了,这个黑客是排在第10个,最高出价人排在第16个,那么最后是有哪些收得到钱,哪些收不到钱,先说受益人收不收得到钱,一开始执行的时候先把钱转给受益人了,这个转账实际上是在干嘛,是你这个矿工或者是全节点执行到beneficiary.transfer的时候把相应账户的余额进行了调整,所有的Solidity语句就是智能合约执行过程中的任何对状态的修改改的都是本地的状态,都是改的本地的数据结构,所以这个合约当中无论是排在黑客合约前面还是后面,都是在改本地数据结构,只不过排在后面的bidder根本没有机会来得及执行,然后整个都回滚了,就好像这个智能合约从来没有被执行过,所以排在前面的这些转账并没有执行,就是改本地结构,然后如果都顺利执行完了,发布出去之后,别的矿工也把这个auctionEnd重头到尾执行一遍,也改它本地的数据结构跟你的能对得上就叫形成共识了,而不是说没有一个转账交易的语句是产生一个新的交易写到区块链上,所以都收不到钱,没有任何一个人能收到钱。
发起这个攻击的有可能是故意捣乱,写这样一个程序让大家都拿不到钱,也可能是这个人不懂,他就忘了写fallback函数了,那出现这种情况怎么办呢?
比如说你发布一个拍卖合约到区块链上,吸引很多人来拍卖,拍卖完之后发现有这样一个问题这个黑客合约,你怎么办?
现在的问题是你已经把钱投进去了,锁在里面了,你怎么把它取出来的问题
答案是没有办法,出现这种情况没有办法了。
大家听说过一种说法吗,Code is law,智能合约的规则是由代码逻辑决定的,而代码一旦发布到区块链上就改不了了,所谓的叫区块链的不可篡改性,这样的好处是没有人能够篡改规则,这样的坏处是规则中有漏洞你也改不了了,要注意,智能合约如果设计的不好的话,有可能把以太币永久的锁起来,谁也取不出来,以前还有用智能合约锁仓的,比如说,我们要开发一个新的加密货币,然后就像前面说的pre-mining先预留一部分币给这个开发者,然后这些币都打到一个智能合约账户锁上三年,三年以后这些币才能卖,这样做是为了大家以后能集中精力开发加密货币,这个挺好的对吧,智能合约锁仓是个常用的操作,万一要是写的时候写错了,多写一个0变成30年,这些币就会锁上30年,没有办法,这个有点像那个不可撤销信托,irrevocable trust,美国有一些有钱人,有这种信托来达到财产保护或者是减税的目的,这个就是一个法律上的合同,如果制定这种不可撤销的信托的时候,法律条款的设计有问题也可能会导致存进去的钱,取不出来,所以在你发布一个智能合约之前一定要测试测试再测试,你可以在专门的那种测试的网上用假的以太币,做测试确认完全没有问题的情况下再发布
那我能不能在这个智能合约里留一个后门,用来修复bug,比如给合约的创建者超级用户的权利,比如在这个构造函数里加一个域叫owner,记录一下这个owner是谁,然后对这个owner的地址允许他做一些系统管理员的操作,比如可以任意转账,把钱转给哪个地址都行
那样的话,如果出现像这种bug,超级管理员就可以发挥作用,把锁进去的钱给转出来了,因为反正对他没有限制,他转给谁都行,这样有可能出现卷款跑路的情况,这样做的前提是所有人都要信任这个系统管理员,这个超级用户,这个跟去中心化的理念是背道而驰的,也是绝大多数区块链的用户不能接受的,回到我们这个程序,那怎么办呢?难道不拍卖了吗?
第二个版本,我们把前面那个auctionEnd拆成两个函数,左边是withdraw,右边是Pay2Beneficiary,
这个withdraw是什么意思呢?我们现在不用循环了,我们在这里就不用循环了,每个竞拍失败的人自己调用withdraw函数,把那一部分钱取回来,首先判断一下拍卖是不是结束了,然后看一看调用的那个人是不是最高出价者,如果是的话,不能把钱给他,因为要留着给那个拍卖的beneficiary,然后看一下这个人账户的余额是不是正的,amount是他的账户余额,msg.sender就是把账户余额转给msg.sender,就是发起调用的这个人,然后把他账户余额清成0,免得他下次再来取一下钱,这是withdraw。
Pay2Beneficiary是说把最高出价给这个受益人,也是判断一下拍卖已经结束了,最高出价的金额大于零,下面再把它转过去,这样可以了吗?
还是有一个问题:重入攻击,如果有黑客写了这样一个程序会怎么样,这个hack_bid跟前面的那个黑客合约hack_bid合约是一样的,通过调用拍卖bid函数参与竞拍,hack_withdraw就在拍卖结束的时候调用withdraw函数,把钱取回来,这两个看上去好像都没有问题,问题在于fallback函数,他又把钱取了一遍,这是我们刚才显示的withdraw函数,hack_withdraw调用withdraw函数的时候,执行到左边这个地方会向黑客合约转账,左边47行这个msg.sender就是黑客的合约,把它当初出价的金额转给他,而右边这个合约在干嘛?他又调用了拍卖函数的withdraw函数,又去取钱,fallback函数这里的msg.sender就是这个拍卖合约,因为是拍卖合约把这个钱转给这个合约的,这个左边的拍卖合约执行到if那里,再给他转一次钱,注意这个清零的操作,把黑客合约账户清零的操作,只有在转账交易完成之后,才会进行,而前面if这个转账的语句已经陷入到了跟黑客合约当中的递归调用当中,根本执行不到下面这个清零操作,所以最后的结果就是这个黑客一开始出价的时候给出了一个价格,拍卖结束之后,就按照这个价格不停地从这个智能合约中去取钱,第一次取得是他自己的出价,后面取得就是别人的钱了。那这个递归重复取钱,持续到什么时候会结束,有三种情况,一个是这个拍卖合约上的余额不够了,不足以在支持这个转账的语句,第二种情况是汽油费不够了,因为每次调用的时候还是消耗汽油费的,到最后没有足够的汽油剩下来了,第三种情况,调用栈溢出了,所以右边部分黑客合约的fallback函数判断一下这个拍卖合约的余额还足以支持转账,当前调用的剩余汽油,msg.gas还有6000个单位以上,调用栈的深度不超过500.那么就再发起一轮攻击,那怎么办呢?
其实最简单的就是先清零再转账,就是我们右边的这种写法,右边Pay2Beneficiary写法是正确的,在这个地方,已经把highestBidder的账户余额清成零了,就在bids哈希表里面的余额已经清成0了,然后再转账,转账如果不成功的话,再把余额恢复,这个实际上是对于可能跟其他合约发生交互的情况的一种经典的编程模式,就先要判断条件,然后改变条件,最后再跟别的合约发生交互,在区块链上,任何未知的合约都可能使有恶意的,所以每次你向对方转账或者使调用对方某个函数的时候,都要提醒下自己,这个合约,这个函数有可能反过来调用你当前的这个合约,并且修改状态,小心一点总是好的
除了这个修改方式以外,还有一种方法,就是不要用call.value的形式转账,对比一下修改前后的两段代码,区别就是绿框的部分,首先我们把清零的位置提前了,先清零再转账,而且呢,我们转账的时候用的使sender,用transfer也可以,sender和transfer一个共同的特点就是转账的时候发送过去的汽油费只有2300个单位,这个不足以让接收的那个合约再发起一个新的调用,只够写一个log而已。
网上拍卖不容易吧,这个时候就没有问题了。
再提一下前面的,黑客合约没有写fallback函数,就左边的那个,如果这个不是一个黑客合约,就是一个普通的账户,他忘了写fallback函数,怎么办?
也没有办法,就算这个账户愿意改,也是改不了的,他没有办法把fallback函数补上了,因为这个发布到区块链上去了,你可以再创建一个新的合约,但是这个合约已经参与这个拍卖了,已经被记录在这个循环里面了,也没有办法。
这篇文章讲的内容比较多,我们讲了智能合约可能出现安全漏洞的例子,那么这些安全漏洞在实际当中是不是真的会发生呢?下一篇讲一些实际的例子。