内容整理自 北京大学肖臻老师《区块链技术与应用》公开课 ETH-22-智能合约
图片均来自视频截图
智能合约是以太坊的精髓,也是以太坊和比特币的主要区别。
智能合约的本质是运行在区块链上的一段代码,代码的逻辑定义了合约的内容。
智能合约的账户保存了合约当前的运行状态:balance 当前余额,nonce 交易次数,code 合约代码,storage 存 储,数据结构是一棵MPT
Solidity是智能合约的最常用的语言,语言上与js接近。如下:
以太坊规定,如果合约账户能够接受外部转账的话,必须标注成payable。否则如果给函数转过去钱的话,会引发错误处理,抛出异常。
这是一个网上拍卖的合约,bid函数是用来竞拍出价的,比如参与拍卖,要出100个以太币,就调用bid函数,拍卖的规则是调用bid函数的时候要把拍卖的出价也发送过去,存储到合约中,锁定到拍卖结束,避免有的人凭空出价。所以bid函数要有接受外部转账的能力。
withdraw函数是拍卖结束,出价最高的人赢得拍卖,其他人没有拍到,其他人可以调用withdraw把自己出的价钱取回来。目的不是为了真的转账,不需要把钱转给智能合约,所以没必要用payable。
和转账类似。比如a发起一个交易转账给b,如果b是一个普通账户,那就是一个普通的转账交易,和比特币转账一样;如果b是一个合约账户,那么这个转账实际上是发起一次对b的合约的调用,具体调用的是合约中的哪个函数,是在数据域(data域)中说明的。
该例子中三个address,是发起调用这个账户的地址,to contract address是bei调用的合约的地址,调用的函数就是TX DATA给出的调用函数,函数有参数的话那么参数的取值也是在data域说明的。中间一行是调用的参数,value是说发起调用的时候转过去多少钱,gas used是这个交易花了多少gas fee,gas price是单位汽油的价格,gas limit是最多愿意支付多少钱。
1.直接调用
2.使用address类型的call函数
两种调用方式的一个区别是对错误处理的不同,第一个是如果调用的合约在执行过程中出现错误,会导致发起调用的合约跟着一起回滚,第二种方式是如果在调用过程中被调用合约抛出异常,call函数会返回false表明调用失败,但是发起调用的函数并不会发生异常,而可以继续执行。
3.代理调用
匿名函数,没有参数和返回值。
调用一个合约时,要在转账交易里的data域说明调用的是哪个函数,如果没有说明,data域是空,那么缺省调用的是fallback函数。还有就是调用的函数不存在,也是调用fallback函数。这也是fallback为什么没有参数和返回值。
如果转账金额不是0,同样需要声明payable, 否则抛异常,一般情况都会设置成payable。
只有合约账户才有fallback以及payable等,外部账户没有。
转账金额可以是0,但是gas fee要有。转账金额是给收款人的,gas fee是给发布区块的矿工的。
智能合约代码写完之后,要编译成bytecode。
创建合约:外部账户发起一个转账交易到0x0的地址,转账金额是0但是要付gas fee。合约的代码放在data域中。
智能合约运行在EVM(Ethereum Virtual Machine)上。通过加一个虚拟机,对智能合约的运行提供一个一致性的平台。
以太坊是一个交易驱动的状态机,调用智能合约的交易发布到区块链上之后,每个矿工都会执行这个交易,从当前状态确定性地转移到下一个状态。
执行合约中的指令要收取汽油费,由发起交易的人来支付。
一个交易的数据结构:
AccountNonce是交易序号,用来防止replay attack。
GasLimit是愿意支付的最大汽油量,Price是单位汽油价格,乘积就是可能消耗的最大汽油费。
Recipient是收款人的地址,Amount是转账金额。
payload就是data域。
当一个全节点收到对智能合约的调用的时候,先按照调用中给出的gas limit算出可能花费的最大汽油费,然后一次性的把汽油费从发起调用的账户上扣掉,然后根据实际执行的情况算出实际花了多少汽油费,如果不够的话会引起回滚。
EVM中不同指令消耗的汽油费是不一样的。简单的指令便宜,比如加法减法,复杂的或者需要存储状态的指令就很贵,比如取哈希。
以太坊中的交易执行起来具有原子性,一个交易要么全部执行,要么全不执行,不会只执行一部分,既包含普通转账交易,也包含对智能合约的调用。所以如果在执行智能合约过程中出现任何错误,会导致整个交易的执行回滚,退回到执行之前的状态。
智能合约中不存在自定义的try-catch结构。
一旦遇到异常,除特殊情况外,本次执行操作全部回滚。
出现错误的一种情况是gas fee不足,合同的执行要退回到之前的状态,而且此时已经消耗的gas fee是不退的,不然可能会有恶意节点发动恶意攻击。
其他情况可以抛出错误的语句:
assert(bool condition)如果条件不满足就抛出--用于内部错误
require(bool condition)如果条件不满足就抛掉--用于输入或者外部组件引起的错误
revert()终止运行并回滚状态变动。
嵌套调用是指一个合约调用另一个合约的函数。
嵌套调用是否会出发连锁式回滚,取决于调用智能合约的方式。如果是直接调用,那就会引发连锁式回滚,整个交易都会回滚;如果使用call方式,不会引起连锁式回滚,只会使当前调用失败,返回值false。
有些情况下,从表面上看,并没有调用任何一个函数, 比如就是往一个账户转账,但是如果账户是合约账户的话,转账那个操作本身就有可能触发对函数的调用,因为有fallback函数,这就是嵌套函数。
block header数据结构中,有一个GasLimit和GasUsed,这两个与GasFee相关,GasUsed是这个区块里所有交易所消耗的gas fee的总和;GasLimit是区块里所有交易能够消耗的gas的上限,不是区块里每个gas limit总和,如果这样的话就等于没有限制。
因为发布区块需要消耗一定的资源,对区块消耗的资源需要有一个限制。发布的区块如果没有任何限制,那么有的矿工可能把特别多的交易全打包到一个区块中,超大的区块在区块链上可能消耗很多资源。比特币中对发布的区块限制大小不能超过1M,比特币的交易是比较简单的,基本上用交易的字节数可以衡量出交易消耗的资源有多少。但以太坊中不行,因为以太坊中智能合约的逻辑很复杂,有的交易可能从字节数上看很小,但是消耗的资源很大,比如调用别的合约,所以需要根据交易的具体操作来收费,就是所谓的gas fee。
以太坊有状态树,交易树,收据树。这三棵树都是全节点在本地维护的树数据结构,状态树记录了账户的状态包括账户余额,所以在全节点收到调用的时候,在本地维护的数据结构里把账户余额减掉gas fee就可以,如果余额不够交易就不能执行,一次性要按gas limit把费用减掉,执行完之后如果有剩余就把余额加回相应数额。
所以当有多个全节点每个节点都扣除一份gas fee,只是在本地的数据结构扣除而已。智能合约执行过程中,任何对状态的修改都是再改本地的数据结构,只有在合约执行完了而且发布到区块上之后,本地的修改才会变成外部可见,才会变成区块链上的共识。
block header数据结构中的root,TxHash,和ReceiptHash(上面block header数据结构图),分别是三棵树的hash值,所以需要先执行完区块中的所有交易,包括智能合约的交易,这样才能更新三棵树 ,才能知道三个rootHash,block header中的内容才能确定,然后才能尝试各个nonce。所以需要先执行智能合约,然后再挖矿。
如果没挖到矿,将不会获得gas fee,因为gas fee是给获得记账权的矿工,以太坊中得不到任何补偿。不仅如此,而且还要把别人发布的交易在本地执行一遍,验证发布区块的正确性,每个全节点要独立验证。别人发布一个交易区块,把区块里的交易在本地执行,更新三棵树的内容,算出roothash,再与发布的roothash进行比较,看是否一致。所有这些都是免费的没有补偿。所以这种机制下挖矿慢的矿工就很吃亏,gas fee设置的目的是对于矿工执行智能合约所消耗的资源的一种补偿,但是只有挖到矿的矿工才能得到,其他矿工得不到。 此外,gas fee也是为了遏制发起调用的账户,如果不给gas fee那么账户可以随意发。
如果出现这种情况,最直接的后果是危害区块链的安全,区块链的安全就是要求所有全节点独立验证发布的区块的合法性,这样少数有恶意的节点才没有办法篡改区块链上的内容。
如果某个矿工不验证, 那么之后就没法挖矿,因为验证的时候是要把区块的交易都执行一遍,更新本地的三棵树,如果不验证本地三棵树内容没有办法更新,本地的状态,算出的hash发布出去之后其他节点认为是错的,没有办法发布区块。所以没有办法跳过验证步骤。
之所以要执行合约才能更新状态,是因为发布的区块中没有三棵树的内容,只是block header中有hash值,三棵树的状态比如具体多少余额,发布出来是没有的。
执行错误的交易也要发布到区块链上,否则gas fee扣不掉,只在本地账户扣gas fee是没用的,需要发布出去形成共识,扣掉的gas fee才成为你账户上的钱,所以发布到区块链上的交易不一定都是成功执行的。需要告诉大家为什么扣gas fee,以及其他节点要验证gas fee的扣除是否合理。如何知道一个交易是不是执行成功?每个交易执行完之后会形成一个收据,下面是收据的内容,其中status域就是说明交易执行情况如何。
solidity不支持多线程,也没有支持多线程的语句。 以太坊是一个交易驱动的状态机,这个状态机必须是完全确定性的,给定的智能合约面对同一组输入,产生的输出,或者说转移到的下一个状态,必须是完全确定的。因为所有的全节点都要执行同一组操作,到达同一个状态,需要验证,如果状态不确定三个树的roothash对不上。多线程的问题在于多个核访问内存的顺序不一致的话,执行结果有可能是不确定的。除了多线程之外,其他可能导致执行结果不一致的操作也都不支持,比如产生随机数,所以以太坊中用的是伪随机数。
智能合约的执行必须是确定性的,这也导致了智能合约不能像通用的编程语言那样通过系统调用来得到一些环境信息,因为每个全节点的执行环境不是完全一样,所以只能通过一些固定变量的值能够得到一些状态信息。下面图就是智能合约能够得到的区块信息。
msg.sender和tx.origin是不一样的,比如有一个外部账户A调用合约C1,合约中有一个函数f1;f1调用另一个合约C2,里面有函数f2,那么对f2来说,msg.sender是C1合约,因为当前调用是C1发起的,但是tx.origin是A账户,交易的发起者。
msg.gas是当前调用还剩多gas fee,决定还能做那些操作,包括调用别的合约是否有足够的gas fee。
msg.data就是所谓的数据域,里面写了调用哪些函数以及函数的参数取值 ,msg.sig是msg.data的前4个字节, 也就是函数标识符。
msg.now是当前区块的时间戳,与区块信息中block.timestamp是一个意思。智能合约中没有办法获得很精确的时间,只能获得当前区块的时间。
第一个是成员变量,其余的都是成员函数。 成员变量是账户余额,类型是uint256,单位很小,是Wei。
addr.transfer的意思不是说addr这个账户往外转多少钱,而是当前合约往addr这个账户转了多少钱。addr不是转出的地址而是转入的地址。
addr.call是一样的语义,不是说addr这个账户发起调用,而是当前合约发起调用,调的是addr这个合约。call和delegatecall区别在于delegatecall不需要切换到被调用函数的环境中,就用当前合约的状态即可。
三种发送ETH的方式:transfer,send,call.value。区别在于transfer和send是专门用于转账的,transfer失败会导致连锁性回滚,相当于直接调用方法;send失败会返回false,不会导致连锁性回滚;call本意是发动函数调用,但也可以转账,也不会引起连锁式回滚,失败返回false。还有一个区别在于transfer和send在转账时只给了2300个单位的gas fee,收到转账的合约基本上做不了什么;而call是把当前调用剩下所有的gas都发过去。
拍卖用到的两个函数:
bid函数是竞拍时用的,想要竞拍,就发起一个交易,调用拍卖合约中的bid函数,拍时出的价格写在msg.value中。
auctionEnd是拍卖结束之后的函数。
智能合约如何工作?
写完一个智能合约之后,比如拍卖程序,要先发布到区块链上,往0的地址发一笔转账交易,转账金额是0,然后把只能合约代码放到data域中,gas fee要交。矿工把智能合约发布到区块链上之后,会返回一个合约地址,然后合约就在区块链上,所有人都可以调用。智能合约本身有一个合约账户,里面有状态信息,成员变量都是存储在MPT中。
拍卖的时候,比如一个外部账户要拍卖,要发起一个交易,交易要调用bid函数,每一次出价参与竞拍,调用bid函数的操作都要写在区块链中,包括转账写在区块链里。
假设有人通过左边这样的一个合约账户参与竞拍 ,会有什么结果?参数是拍卖合约的地址,转成拍卖合约的一个实例,然后调用拍卖合约的bid函数,把钱发送过去。合约账户不能自己发起交易,所以得有一个黑客从他的外部账户发起交易,调用这个合约账户的hack_bid函数,然后hack_bid再去调用拍卖合约中的bid账户。
左边合约参与拍卖没有问题,拍卖结束退款的时候会有问题。当一个合约账户收到转账没有调用任何函数的时候,应该调用fallback函数,但是左边合约没有调用fallback函数,所以会调用失败,调用失败会抛出异常,transfer函数会引起连锁式回滚,所以导致转账操作是失败的,收不到钱。
转账过程实际上是是全节点执行到transfer的时候把相应账户的余额进行了调整,所有智能合约执行过程中的任何对状态修改的语句改的都是本地的状态和数据结构,无论是排在黑客前面还是后面,整个都回滚,都收不到钱。出现这种情况没有办法。智能合约设计的不好的话,有可能把收到的以太币永久锁起来,谁也取不出。
第二版本,把actionEnd拆成两个函数,左边是withdraw右边是pay2Beneficiary。withdraw意思是不用再循环,每个竞拍没有成功的人自己调用函数把钱取回来,pay2Beneficiary意思是拍最高出价给受益人。但是有个问题就是重入攻击。
当合约账户收到ETH但未调用函数时,会立刻执行fallback函数。
通过addr.send(),addr.transer(),addr.call.value()三种方式付钱都会触发addr里的fallback函数
fallback函数由用户自己编写。
如果有这样一个程序,hack_bid和前面一样,拍卖结束时调用hack_withdraw取回钱,问题在于fallback函数,又把钱取了一遍。
hack_withdraw调用拍卖合约的withdraw的时候,执行到if的时候会向黑客合约转账,msg.sender就是黑客的合约,把当初出价的金额转给黑客,而fallback再次调用拍卖合约的withdraw,又去取钱,这时的msg.sender就是拍卖合约,因为是拍卖合约把钱转给黑客合约,而拍卖合约又执行一遍,到if再一次转钱。withdraw中清零操作只有在转账交易完成之后才会运行,而转账语句已经陷入到与黑客合约的递归调用中,执行不到清零操作,所以结果就是黑客一开始出价的时候给出一个价格,拍卖结束之后就按照这个价格不停地从拍卖合约中去取钱,第一次是自己的出价,后面取的就是别人的钱。
递归重复取钱结束,有三种情况: 一是拍卖合约的余额不足以支持转账,二是gas fee不够,三是调用栈溢出。
一个简单的解决方式就是先清零再转账,转账如果不成功再把余额恢复。先判断条件,再改变条件,最后和别的合约发生交互。区块链上任何未知的合约都有可能是恶意的。
如下,还有一种方式就是不要用call.value的方式转账,而是使用addr.send或transfer的方式转账,因为这两种一个特点就是转账时发送过去的gas fee只有2300个单位,不足以让接收的合约再发起一个新的调用。