北京大学肖臻老师《区块链技术与应用》公开课笔记:以太坊原理(三):智能合约

9、ETH-智能合约

智能合约是以太坊的精髓,也是以太坊和比特币一个最大的区别

1)、什么是智能合约

智能合约的本质是运行在区块链上的一段代码,代码的逻辑定义了智能合约的内容

智能合约的账户保存了合约当前的运行状态

  • balance:当前余额
  • nonce:交易次数
  • code:合约代码
  • storage:存储,数据结构是一棵MPT

Solidity是智能合约最常用的语言,语法上与JavaScript很接近

2)、智能合约的代码结构

Solidity是面向对象的编程语言,这里的contract类似于C++当中的类class,这里的contract定义了很多状态变量,Solidity是强类型语言,这里的类型跟普通的编程语言像C++之类的是比较接近的,比如说uint(unsigned int)是无符号的整数,address类型是Solidity语言所特有的

接下来是两个event事件,作用是用来记录日志的

第一个事件是HighestBidIncreased,拍卖的最高出价增加了,上图是一个网上拍卖的例子,如果有人出现新的最高价,记录一下参数是address bidder,金额是amount,第二个事件是Pay2Beneficiary,参数是赢得拍卖的人的地址winner以及他最后的出价amount

Solidity语言跟别的普通编程语言相比有一些特别之处:

比如mapping,mapping是一个哈希表,保存了从地址到unit的一个映射。Solidity语言中哈希表不支持遍历,如果想遍历哈希表里的所有元素,需要自己想办法记录哈希表中有哪些元素,这里是用bidders数组来记录的。Solidity语言中的数组可以是固定长度的,也可以是动态改变长度的,这里是一个动态改变长度的数组。如果想在数组里增加一个元素,就用push操作,bidders.push(bidder),新增加一个出价人在数组的末尾,要想知道这个数组有多少个元素,可以用bidders.length,如果是固定长度的数组的话,就要写明数组的长度,比如说address[1024],这个就是长度为1024的数组

再往下是构造函数,构造函数只能有一个,Solidity语言中定义构造函数有两种方法

  • 一种方法就是像C++构造函数一样,定一个与contract同名的函数,这个函数可以有参数,但是不能有返回值
  • 新版本Solidity语言更推荐用这个例子的方法,就用一个constructor来定义一个构造函数,这个构造函数只有在合约创建的时候会被调用一次

接下来是三个成员函数,三个函数都是public,说明其他账户可以调用这些函数

3)、账户调用

1)外部账户如何调用智能合约?

调用智能合约其实跟转账是类似的,比如说A发起一个交易转账给B:

  • 如果B是一个普通的账户,那么这就是一个普通的转账交易,就跟比特币当中的转账交易时一样的
  • 如果B是一个合约账户的话,那么这个转账实际上是发起一次对B这个合约的调用,那么具体是调用合约中的哪个函数呢,是在data域(数据域)说明的

上图这个例子中,sender address是发起这个调用的账户的地址,to contract address是被调用的合约的地址,调用的函数是txdata,如果函数是有参数的话,那么参数的取值也是在data域里说明的,上面看的网上拍卖的例子中,三个成员函数都没有参数,但是有的成员函数是可以有参数的

中间那一行是调用的参数,value是说发起调用的时候转过去多少钱,这里是0,这个调用的目的仅仅是为了调用它的函数,并不是真的要转帐,所以value=0,gas used是这个交易花了多少汽油费,gas price是单位汽油的价格,gas limit是这个交易我最多原意支付多少汽油费

2)一个合约如何调用另一个合约中的函数?

方法一:直接调用

在这里插入图片描述

上图这个例子中,有A和B两个合约:

A这个合约就只是写log,event定义事件LogCallFoo,emit LogCallFoo()是用emit这个操作来调用这个事件,emit语句的作用就是写一个log,对于程序的运行逻辑是没有影响的

B这个合约,callAFooDirectly这个函数参数是一个地址,就是A这个合约的地址,然后就这个语句把这个地址转换成A这个合约的一个实例,然后调用其中的foo这个函数

以太坊中规定一个交易只有外部账户才能够发起,合约账户不能自己主动发起一个交易。所以这个例子中需要有一个外部账户调用了合约B当中的这个callAFooDirectly函数,然后这个函数再调用合约A当中的foo函数

方法二:使用address类型的call()函数

在这里插入图片描述

address类型的call()函数,第一个参数是要调用函数的签名,然后后面跟的是调用的参数

这种调用的方法跟上一个调用的方法相比,一个区别是对于错误处理的不同,直接调用时,如果你调用了那个合约在执行过程中出现错误,那么会导致发起调用的这个合约也跟着一起回滚,在直接调用的例子中如果A在执行过程出现什么异常,会导致B这个合约也跟着一起出错

而这种address.call()这种形式如果在调用过程中,被调用的合约抛出异常,那么这个call函数会返回false,表明这个调用是失败的,但是发起调用的这个函数并不会抛出异常,而是可以继续执行

方法三:代理调用delegatecall()

在这里插入图片描述

代理调用和call()这种方法基本上是一样的,一个主要的区别是delegatecall不需要切换到被调用的合约的环境中去执行,而是在当前合约环境中执行就可以了,比如就用当前账户的账户余额存储之类的

4)、payable

在这里插入图片描述

上图中,bid函数有一个payable,另外两个函数都没有。以太坊中规定如果这个合约账户要能接收外部转账的话,那么必须标注成payable

这个例子中bid函数是什么意思?

这是一个网上拍卖的合约,bid函数是用来进行竞拍出价的,比如说你要参与拍卖,你说你出100个以太币,那么就调用合约当中的bid函数。拍卖规则是调用bid函数时要把拍卖的出价100个以太币也发送过去,存储到这个合约里,锁定到拍卖结束,避免有人凭空出价,所以这个bid函数要有能够接收外部转账的能力,才标注一个payable

第二个withdraw函数没有payable,withdraw是拍卖结束了,出价最高的那个人赢得了拍卖,其他人没有拍到想要的东西,可以调用withdraw把自己当初出的价钱,就是原来bid的时候锁定在智能合约里的以太币再取回来,因为这个的目的不是为了真的转账,不是要把钱转给智能合约,而仅仅是调用withdraw函数把当初锁定在智能合约里的那一部分钱取回来,所以没必要标注payable

在这里插入图片描述

上图转账交易的例子,value=0,这个交易就属于并没有真的把钱转出去,所以to contract address这个函数就不用定义成payable

以太坊中凡是要接收外部转账的函数,都必须标识为payable,否则你给这个函数转出钱的话,会引发错误处理,会抛出异常,如果你不需要接收外部转账你就不用标识为payable

5)、fallback()函数

在这里插入图片描述

有一个特殊的函数叫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()函数不是必须定义的,合约里可以没有fallback()函数,如果没有fallback()函数的话,出现前面说的几种情况,就会抛出异常。另外只有合约账户才有这些东西,外部账户跟这个都没有关系,外部账户都没有代码

还有一点,转账金额可以是0,但是汽油费是要给的,这是两码事,转账金额是给收款人的,汽油费是给发布这个区块的矿工的,如果汽油费不给的话,矿工不会把你这个交易打包发布到区块链

6)、智能合约的创建和运行

在这里插入图片描述

智能合约是怎么创建的呢?是由一个外部账户发起一个转账交易,转给0x0这个地址,然后把这个要发布合约的代码放到data域里面。你要创建一个合约,要发起一个转账交易,给0这个地址转账,转账的金额都是0,因为你实际上不是真的想转帐,只是想发布一个智能合约,发布的这个智能合约的代码放到数据域就行了

合约的代码写完之后都是要编译成bytecode,然后运行在EVM上。EVM是类似于JVM的设计思想,通过加一层虚拟机,对智能合约的运行提供一个一致性的平台,所以EVM有时叫做Worldwide Computer(全世界的一个计算机),EVM的寻址空间是非常大,是256位,像前面讲的unsigned int就是256位

7)、汽油费(gas fee)

在这里插入图片描述

比特币和以太坊这两种区块链的编程模型,设计理念是有很大差别的

比特币设计理念是简单,脚本语言的功能很有限,比如说不支持循环

而以太坊是要提供一个图灵完备的编程模型(Turing-complete Programming Model),很多功能在比特币平台上实现起来很困难,甚至是根本实现不了,而到以太坊平台上呢,实现起来就很容易,当然,这样也带来一个问题,出现死循环怎么办,当一个全节点收到一个对智能合约的调用,怎么知道这个调用执行起来会不会导致死循环?有什么办法吗?

没有办法,这实际上是一个Halting Problem(停机问题),停机问题是不可解的,从理论上可以证明不存在这样一个算法,能够对任意给定的输入程序判断出这个程序是否会停机。那怎么办呢?办法就是把这个问题推给发起交易的那个账户,以太坊引入了汽油费机制,发起一个对智能合约的调用要支付相应的汽油费

上图中间是一个交易的数据结构:

  • AccountNonce就是这个交易的序号,用于防止replay attack
  • Price和GasLimit就是跟汽油费相关的,GasLimit是这个交易原意支付的最大汽油量,Price是单位汽油的价格,两个乘在一起就是这个交易可能消耗的最大汽油费
  • Recipient就是收款人的地址,转账交易转给谁的收款人地址
  • Amount是转账金额,把Amount这么多钱转给Recipient,也可以看到交易当中的汽油费跟转账金额是分开的
  • Payload就是前面说的data域,用于存放调用的是合约中的哪一个函数,函数的参数取值是什么,都在Payload里面

当一个全节点收到一个对智能合约的调用的时候,先按照调用过程中给出的GasLimit算出可能花掉的最大汽油费,然后一次性的把这个汽油费从这个发起调用的账户上扣掉,然后再根据实际执行的情况,算出实际花了多少钱,如果汽油费不够的会引起回滚

不同的指令消耗的汽油费是不一样的。一些简单的指令,比如说加法减法消耗的汽油费是很少的,复杂的指令消耗的汽油费就比较多,比如说取哈希,这个运算一条指令就可以完成,但是汽油费就比较贵,除了计算量之外,需要存储状态的指令消耗的汽油费也是比较大的,那么相比之下,如果仅仅是为了读取公共数据,那么那些指令可以是免费的

8)、错误处理

在这里插入图片描述

以太坊中的交易执行起来具有原子性,一个交易要么全部执行,要么完全不执行,不会只执行一部分,这个交易既包含普通的转账交易,也包含对智能合约的调用,所以如果在执行智能合约的过程当中,出现任何错误,会导致整个交易的执行回滚,退回到开始执行的之前的状态,就好像这个交易完全没有执行过

那么什么情况下会出现错误呢?

一种情况就是刚才说的汽油费,如果这个交易执行完之后,没有达到当初的GasLimit,那么多余的汽油费会被退回到这个账户里,一开始的时候是按照最大的GasLimit把汽油费扣掉了,如果最后运行完了,还有剩下来的,实际上是用的多少汽油收多少钱,剩的可以退回去。相反,如果执行到一半,GasLimit已经都用完了,那么这个时候这个合约的执行要退回到开始执行之前的状态,这就是一种错误处理,而且这个时候已经消耗掉的汽油费是不退的

为什么要这么设计呢?执行的状态要回滚,但已经耗掉的汽油费是不退的

因为要么的话就会有恶意的节点可能会发动delays service attack,可能他发布一个计算量很大的合约,然后不停的调这个合约,每次调的时候给的汽油费都不够,反正最后汽油费还会退回来,那么对攻击者来说没有什么损失,但是对矿工来说是白白浪费了很多的资源,这就是为什么说,汽油费不够的话,执行到一半会回滚,花掉的汽油费是不退的

除了这种汽油费不够的情况,还有一种情况是引起错误处理的,比如说assert语句和require语句,这两个语句都是用来判断某种条件,如果条件不满足的话,就会导致抛出异常

assert语句一般用于判断某种内部条件,有点像C语言中的assert是一样的,require语句一般用于判断某种外部条件,比如说判断函数的输入是否符合要求。上图中给出了一个简单的例子,bid这个竞拍的函数判断一下,当前的时间now<=拍卖的结束时间auctionEnd,如果符合条件继续执行,如果不符合的话,拍卖都已经结束了,你还在出价,这个时候就会抛出异常

第三个语句是revert,revert是无条件的抛出异常,如果执行到revert语句,那么自动的就会导致回滚,早期的版本里用的是throw语句,新版本Solidity里建议改用revert这个语句

Solidity当中没有这种try-catch这种结构,有的编程语言像Java,用户自己可以定义出现问题后怎么办,有这种try-catch,Solidity里没有这种结构

9)、嵌套调用

在这里插入图片描述

智能合约出现错误会导致回滚,那么如果是嵌套调用,一个智能合约调用另外一个智能合约,那么被调用的这个智能合约出现错误,是不是会导致发起调用的智能合约,也跟着一起回滚呢?所谓的叫连锁式回滚

不一定,这个取决于调用这个智能合约的方式。如果是直接调用的话,会出现连锁式的回滚,整个交易都会回滚,如果调用的方式是用比如说call这种方式,就不会引起连锁式回滚,只会使当前的调用失败返回一个false的返回值

有些情况下,从表面上看你并没有调用任何一个函数,比如说,你就是往一个账户里转账,但是这个账户是合约账户的话,转账这个操作本身就有可能触发对函数的调用,因为有fallback()函数,这就是一种嵌套调用,一个合约往另一个合约里转账,就有可能调用这个合约里的fallback函数

10)、Block Header中的GasLimit和GasUsed

在这里插入图片描述

Block Header中的GasLimit和GasUsed也是跟汽油费相关的,Block Header里面的GasUsed是这个区块里所有交易所消耗的汽油费加在一起

发布区块需要消耗一定的资源,这个消耗的资源要不要有一个限制,比特币当中对于发布的的区块也是有一个限制的,大小的限制,最多不能超过1M,因为发布的区块如果没有任何限制,有的矿工可能把特别多的交易全部打包到一个区块里面然后发布出去,那么这个超大的区块在区块链上会消耗很多资源,所以它规定每个区块最多不能超过1M,比特币交易是比较简单的,基本上可以用交易的字节数来衡量出这个交易消耗的资源有多少,但以太坊中如果这么规定是不行的,因为以太坊中智能合约的逻辑很复杂,有的交易可能从字节数上看是很小的,但它消耗的资源可能很大,比如它可能调用别的合约之类的,所以要根据交易的具体操作来收费,这就是汽油费

Block Header里面的GasLimit是这个区块里所有交易能够消耗的汽油的一个上限,不是说把区块里每个交易的GasLimit加在一起,如果那样的话,就等于没有限制了,因为每个交易的GasLimit是发布这个交易的账户自己定的,定多少是自己说了算,但是这个区块中的所有交易,实际能够消耗的汽油是有一个上限的,不能无限的消耗,否则你也可能发布一个对资源消耗很大的区块,对整个系统的运行是没有好处的

GasLimit跟比特币的区别:

比特币限制资源是按照大小来限制的,而且这个1M的上限是固定了的,是写死在协议里面的,有些人认为1M太小了,而且有的分叉币的产生就是为了提高这个上限

以太坊中也有一个上限,这个GasLimit,但是每个矿工在发布区块的时候可以对GasLimit进行微调,可以在上一个GasLimit的基础上上调或者下调 1 1024 \frac{1}{1024} 10241。如果出现像比特币那种情况,大家都觉得这个GasLimit设置的太小了,那轮到你发布区块的时候可以增加 1 1024 \frac{1}{1024} 10241,1/1024听起来很小,以太坊的出块速度很快,十几秒就是一个新的区块,所以的话,如果大家都觉得当前的GasLimit太小,那么很快就可以翻一番。当然,也可能下调,有矿工认为GasLimit太大了需要下调,所以这种机制实际上求出的GasLimit,是所有矿工认为比较合理的GasLimit的一个平均值,有的矿工认为要上调,有的矿工认为要下调,那么每个矿工在获得记账权之后就按照自己的意愿进行这种上调或者下调的微调,所以最后整个系统的GasLimit就趋向于所有矿工的一个平均意见

11)、一些问题

问题1:某个全节点要打包一些交易到一个区块里面,这些交易里有一些是对智能合约的调用,那么这个全节点应该先把这个智能合约都执行完之后再去挖矿呢,还是说先挖矿获得了记账权然后再执行这些智能合约?

区块链里有一笔转账交易发布上去的话,本来就是需要所有的全节点都执行的,这不是一种浪费也不是一种出问题了,就是所有的全节点要同步状态,大家都要在本地执行这个转账交易,如果一个全节点不执行那就出问题了,那他的状态跟别人的状态是不一样的,比特币也是一样的,比特币发布一个交易到区块链上,也是要所有的全节点都得执行这个转账交易,要不然怎么更新UTXO啊

先往回退一步,不回答这个问题,在全节点收到一个对合约的调用的时候,要一次性的先把这个调用可能花掉的最大汽油费从发起这个调用的账户上扣掉,这个具体是怎么操作的?

状态树、交易树和收据树,这三棵树都是全节点在本地维护的数据结构,状态树记录了每个账户的状态包括账户余额,所以扣汽油费的时候实际怎么扣的?全节点收到调用的时候,从本地维护的数据结构里把账户的余额减掉就行了,如果余额不够的话,这个交易就不能执行,一次性要按GasLimit把他这个余额减掉,执行完之后如果有剩的,再把他的余额再加回去一点

智能合约执行过程中任何对状态的修改都是在改本地的数据结构,只有在合约执行完了,而且发布到区块链上之后,本地的修改才会变成外部可见的,才会变成区块链上的共识。有很多全节点,每个全节点都在本地做这个事情,执行的智能合约可能不完全一样,因为根据你收到的交易可能执行不完全一样,如果某个全节点发布一个区块,我收到这个区块之后,我本地执行的就扔掉了,我把收到这个区块里的交易再执行一遍,更新我本地的三棵树。如果我本来已经执行一遍了,我没有挖到矿,那个人发过来我又得执行一遍,我得执行两遍多浪费啊,问题是你不这样还能怎么办,你本地那个候选区块中包含的交易跟他发布的那个交易不一定完全一样,至少有一个肯定不一样,给出块奖励的那个肯定不一样,他不会给你,别的交易也不一定就一样,所以这个没有办法,都是得要重新执行一遍

以太坊挖矿其实也是尝试各种nonce找到一个符合要求的,计算哈希的时候要用到什么?要用到这个Block Header的内容,Block Header的内容这个Root、TxHash、ReceiptHash,是那三棵树的根哈希值,所以要先执行完这个区块中的所有交易包括智能合约的交易,这样才能更新这三棵树,这样才能知道这三个根哈希值,这样这个Block Header的内容才能确定,然后才能尝试各个nonce

问题2:假设我是一个矿工我费了半天劲执行这些智能合约,消耗了我本地的好多资源,最后我挖矿没挖到怎么办,因为挖矿是竞争,很多矿工竞争,记账权被别人抢先了,那我能得到什么补偿,我能得到汽油费吗?

汽油费是没有的,因为汽油费是给获得记账权发布区块的那个矿工,那我能得到啥补偿?以太坊中没有任何补偿,他得不到汽油费也得不到任何补偿,不仅如此,他还要把别人发布的区块里的交易在本地执行一遍,以太坊中规定要验证发布区块的正确性,每个全节点要独立验证,那怎么验证呢?别人发布一个交易区块,你把那个区块里的所有交易在本地执行完一遍,更新三棵树的内容,算出根哈希值,再跟他发布的那个根哈希值比较一下看是不是一致,所有这些都是免费的,没有人给你补偿。所以呢,这种机制下,挖矿慢的矿工就特别吃亏,本来汽油费的设置的目的是对于矿工执行这些智能合约所消耗的这些资源的一种补偿,但是这种补偿只有挖到矿的矿工才能得到,其他的矿工等于是陪太子读书

问题3:会不会有的矿工你不给我汽油费,那我就不验证?比如说我挖半天没有挖到矿,你发布一个区块,按照协议我要验证一下你这个区块的正确性,我验证他有啥好处,你又不给我汽油费,我验证他干嘛,我就认为你是正确的不就行了吗,我就接着挖,会不会有矿工想不通?

先说一下,如果这样做会导致什么后果,最直接的后果是危害区块链的安全,区块链的安全是是怎么保证的,就是要求所有的全节点要独立验证发布的区块的合法性,这样少数有恶意的节点没法篡改区块链上的内容。如果某个矿工想不通,不给钱我就不验证了,这样的风气蔓延开来就会危及区块链的安全

会不会有这样的情况?如果他跳过验证这个步骤,他以后就没法再挖矿了,因为你验证的时候是要把区块的交易再执行一遍,更新本地的那三棵树,如果不去验证的话,本地三棵树的内容没有办法更新,以后再发布区块你怎么发布,你本地的这些状态就不对了,你算出的根哈希值发布出去之后别人认为是错的。没有办法跳过验证这个步骤

为什么要执行才能更新状态?因为发布的区块里没有这三棵树的内容,只是块头里有三个根哈希值,这三棵树的账户状态具体是什么余额什么内容,发布出来是没有的,不能把状态树的整个状态发布到区块链上,那太多了,而且很多是重复的,状态都不改了,所以不会跳过验证这个步骤,以太坊的安全还是有保证的

问题4:发布到区块链上的交易是不是都是成功执行的?如果智能合约执行过程中出现了错误,要不要也发布到区块链上去?

执行发生错误的交易也要发布到区块链上去,否则汽油费扣不掉,光是在本地的数据结构上把他的账户扣了汽油费,是没用的,你拿不到钱,你得把区块发布上去之后形成共识,扣掉的汽油费才能成为你账户上的钱,所以发布到区块链上的交易不一定都是成功执行的。要告诉大家为什么扣汽油费,而且别人得验证一遍,也要把这个交易执行完一遍,看你扣的是不是对的

在这里插入图片描述

那怎么知道一个交易是不是执行成功了呢,前面说过那三棵树,每个交易执行完后形成一个收据,上图是这个收据的内容,Status这个域就是告诉你交易执行的情况是怎么样的

问题5:智能合约是不是支持多线程,现在多核处理器很普遍,一个计算器有十几核,几十个核,都是正常的,那么智能合约支不支持多核并行处理?

Solidity不支持多线程,它根本没有支持多线程的语句,原因是以太坊是一个交易驱动的状态机,这个状态机必须是完全确定性的,就是给定一个智能合约,面对同一组输入,产生的输出或者说转移到的下一个状态必须是完全确定的

为什么要求这个?因为所有的全节点都得执行同一组操作到达同一个状态,要验证,如果状态不确定的话,那三棵树得根哈希值根本对不上,必须完全确定才行

多线程的问题在于什么?多个核对内存访问顺序不同的话,执行结果有可能是不确定的,除了多线程之外,其他可能造成执行结果不确定的操作也都不支持,最直接最简单的会导致执行结果不确定的操作:产生随机数,这个操作就是不确定性的,而且这个操作必须得是不确定的,所以以太坊的智能合约没有办法产生真正意义下的随机数,可以用一些伪随机数,不能是真的随机数,否则的话,又会出现前面的问题,每个全节点执行完一遍得到的结果都不一样

13)、智能合约可以获得的信息

1)区块信息

在这里插入图片描述

智能合约的执行必须是确定性的,这也就导致了智能合约不能像通用的编程语言那样通过系统调用来得到一些环境信息,因为每个全节点的执行环境不是完全一样的,所以它只有通过一些固定的一些变量的值能够得到一些状态信息,上图就是智能合约能够得到的区块链的一些信息

2)调用信息

在这里插入图片描述

上图是智能合约可以获得的调用信息:

  • msg.sender是发起这个调用的人是谁,这个跟最后一个tx.origin交易的发起者是不一样的。比如说有一个外部账户A调用了一个合约叫 C 1 C_1 C1 C 1 C_1 C1当中有一个函数 f 1 f_1 f1 f 1 f_1 f1又调用另外一个合约 C 2 C_2 C2,里面的函数 f 2 f_2 f2,那么对这个 f 2 f_2 f2函数来说,msg.sender C 1 C_1 C1这个合约,因为当前这个调用,是 C 1 C_1 C1这个合约发起的,但是tx.origin是A这个账户,因为整个交易的发起者是A这个账户
  • msg.gas是当前调用还剩下多少汽油费,这个决定了我还能做哪些操作,包括你还想调用别的合约前提是还有足够的汽油费剩下来
  • msg.data就是所谓的叫数据域,在里面写了调用哪些函数和这些函数的参数取值
  • msg.sigmsg.data的前四个字节,就是函数标志符调用的是哪个函数
  • now是当前区块的时间戳,跟区块信息中的block.timestamp是一个意思,就是智能合约里没有办法获得很精确的时间,只能获得跟当前区块信息的一些时间

14)、地址类型

在这里插入图片描述

上图中第一个是个成员变量,剩下的都是成员函数。成员变量就是账户的余额balance,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)(),最后一个参数如果不用调用函数可以是空的。区别在于transfer和send是专门用来转账的,call的话本意是发动函数调用,但是也可以用来转账,call也不会引起连锁式回滚,失败时返回false

另外一个区别是transfer和send在发起调用的时候,只给了一点儿的汽油,是2300个单位,非常少的,那么收到这个转账的合约基本上干不了别的事,写一个log就行了,别的事都干不了,而call是把当前这个调用剩下的所有的汽油都发过去,比如说call所在的合约本身被调用的时候,可能还剩8000个汽油,然后去调别的合约的时候如果是用call这种方法去转账,就把剩多少汽油都发过去了

15)、拍卖的例子

在这里插入图片描述

回到一开始讲的拍卖的例子,拍卖有一个受益人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

auctionEnd这个函数必须要某个人调用才能执行,这个也是Solidity语言跟其他编程语言不同的一个地方,就是没有办法把它设置成拍卖结束了自动执行auctionEnd,可能是拍卖的受益人beneficiary去调用这个auctionEnd,也可能是参与竞拍没有成功的人去调用,总之得有一个人去调用。如果两个人都去调用auctionEnd,矿工在执行的时候把第一个调用执行完了,然后第二个再执行就执行不了了,因为第一个执行完之后,ended就是true了,没有并发执行

在这里插入图片描述

假设有一个人通过上图这样的一个合约账户参与竞拍,会有什么结果?

这个合约实际上就一个函数hack_bid,这个函数的参数是拍卖合约的地址,然后把它转成这个拍卖合约的一个实例,然后调用拍卖合约用的bid函数,把这个钱发送过去。这是一个合约账户,合约账户不能自己发起交易,所以实际上得有一个黑客从他自己的外部账户发起一个交易,调用这个合约账户的hack_bid函数,然后这个函数再去调用拍卖合约的bid函数,把这个黑客外部账户转过来的钱再转给这个拍卖合约中的bid函数,就参与拍卖了

在这里插入图片描述

这个合约参与拍卖没有问题,最后拍卖结束退款的时候会有什么问题?这个红框里循环退款,退到合约账户上的钱会有什么情况,退到黑客合约账户上的钱会有什么情况?

黑客外部账户对拍卖合约来说是不可见的,拍卖合约能看到的只是这个黑客的合约。转账的时候没有调用任何函数,那么当一个合约账户收到转账没有调用任何函数的时候应该调用fallback函数,而这个合约没有定义fallback函数,所以会调用失败,会抛出异常,这个transfer函数会引起连锁式的回滚,就会导致这个转账操作是失败的,所有人都收不到钱了

再具体点,比如有20个人参与竞拍了,这个黑客是排在第10个,最高出价人排在第16个,那么最后是有哪些收得到钱,哪些收不到钱?

这个转账实际上是全节点执行到beneficiary.transfer的时候把相应账户的余额进行了调整,所有的Solidity语句就是智能合约执行过程中的任何对状态的修改改的都是本地的状态,都是改的本地的数据结构。所以这个循环当中无论是排在黑客合约前面还是后面,都是在改本地数据结构,只不过排在后面的bidder根本没有机会来得及执行,然后整个都回滚了,就好像这个智能合约从来没有被执行过。所以排在前面的这些转账并没有执行,就是改本地结构,然后如果都顺利执行完了,发布出去之后,别的矿工也把这个auctionEnd重头到尾执行一遍,也改它本地的数据结构,跟你的能对得上就叫形成共识了,而不是说每有一个转账交易的语句是产生一个新的交易写到区块链上。所以都收不到钱,没有任何一个人能收到钱

发起这个攻击的有可能是故意捣乱,写这样一个程序让大家都拿不到钱,也可能是这个人不懂,他就忘了写fallback函数了,那出现这种情况怎么办呢?比如说你发布一个拍卖合约到区块链上,吸引很多人来拍卖,拍卖完之后发现有这样一个问题这个黑客合约,你怎么办?

现在的问题是你已经把钱投进去了,锁在里面了,你怎么把它取出来。答案是没有办法,出现这种情况没有办法了。Code is law,智能合约的规则是由代码逻辑决定的,而代码一旦发布到区块链上就改不了了,所谓的叫区块链的不可篡改性,这样的好处是没有人能够篡改规则,这样的坏处是规则中有漏洞你也改不了了

智能合约如果设计的不好的话,有可能把以太币永久的锁起来,谁也取不出来,所以在你发布一个智能合约之前一定要测试测试再测试,你可以在专门的那种测试的网上用假的以太币,做测试确认完全没有问题的情况下再发布

那我能不能在这个智能合约里留一个后门,用来修复bug,比如给合约的创建者超级用户的权利,在这个构造函数里加一个域叫owner,记录一下这个owner是谁,然后对这个owner的地址允许他做一些系统管理员的操作,比如可以任意转账,把钱转给哪个地址都行

那样的话,如果出现像这种bug,超级管理员就可以发挥作用,把锁进去的钱给转出来了,因为反正对他没有限制,他转给谁都行。但这样有可能出现卷款跑路的情况,这样做的前提是所有人都要信任这个超级用户,这个跟去中心化的理念是背道而驰的,也是绝大多数区块链的用户不能接受的

在这里插入图片描述

第二个版本,把前面那个auctionEnd拆成两个函数,左边是withdraw,右边是Pay2Beneficiary

withdraw函数这里就不用循环了,每个竞拍失败的人自己调用withdraw函数,把那一部分钱取回来。首先判断一下拍卖是不是结束了,然后看一看调用的那个人是不是最高出价者,如果是的话,不能把钱给他,因为要留着给那个拍卖的beneficiary,然后看一下这个人账户的余额是不是正的,amount是他的账户余额,把账户余额转给msg.sender,就是发起调用的这个人,然后把他账户余额清成0,免得他下次再来取一下钱

Pay2Beneficiary函数是说把最高出价给这个受益人,也是判断一下拍卖已经结束了,最高出价的金额大于零,下面再把它转过去

这样可以了吗?

在这里插入图片描述

还是有一个问题:重入攻击,如果有黑客写了上图右边这样一个程序会怎么样?

这个hack_bid跟前面的那个黑客合约hack_bid合约是一样的,通过调用拍卖bid函数参与竞拍,hack_withdraw就在拍卖结束的时候调用withdraw函数,把钱取回来,这两个看上去好像都没有问题

问题在于fallback函数,他又把钱取了一遍,左边是智能合约中的withdraw函数,hack_withdraw调用withdraw函数的时候,执行到左边第47行会向黑客合约转账,这个msg.sender就是黑客的合约,把它当初出价的金额转给他,而右边这个合约在干嘛?它又调用了拍卖函数的withdraw函数,又去取钱,fallback函数这里的msg.sender就是这个拍卖合约,因为是拍卖合约把这个钱转给黑客合约的,这个左边的拍卖合约执行到if那里,再给他转一次钱

注意这个清零的操作,把黑客合约账户清零的操作,只有在转账交易完成之后,才会进行,而第47行这个转账的语句已经陷入到了跟黑客合约当中的递归调用当中,根本执行不到下面这个清零操作,所以最后的结果就是这个黑客一开始出价的时候给出了一个价格,拍卖结束之后,就按照这个价格不停地从这个智能合约中去取钱,第一次取得是他自己的出价,后面取得就是别人的钱了

那这个递归重复取钱,持续到什么时候会结束?有三种情况,一个是这个拍卖合约上的余额不够了,不足以在支持这个转账的语句;第二种情况是汽油费不够了,因为每次调用的时候还是消耗汽油费的,到最后没有足够的汽油剩下来了;第三种情况,调用栈溢出了。所以右边部分黑客合约的fallback函数判断一下这个拍卖合约的余额还足以支持转账,当前调用的剩余汽油msg.gas还有6000个单位以上,调用栈的深度不超过500,那么就再发起一轮攻击

在这里插入图片描述

其实最简单的就是先清零再转账,就是我们右边的这种写法,右边Pay2Beneficiary写法是正确的,已经把highestBidder的账户余额清成零了,就在bids哈希表里面的余额已经清成0了,然后再转账,转账如果不成功的话,再把余额恢复

这个实际上是对于可能跟其他合约发生交互的情况的一种经典的编程模式,就先要判断条件,然后改变条件,最后再跟别的合约发生交互。在区块链上,任何未知的合约都可能使有恶意的,所以每次你向对方转账或者使调用对方某个函数的时候,都要提醒下自己,这个合约,这个函数有可能反过来调用你当前的这个合约,并且修改状态,小心一点总是好的

在这里插入图片描述

还有一种方法,就是不要用call.value的形式转账,对比一下修改前后的两段代码,区别就是绿框的部分。首先我们把清零的位置提前了,先清零再转账,而且转账的时候用的使sender,用transfer也可以,sender和transfer一个共同的特点就是转账的时候发送过去的汽油费只有2300个单位,这个不足以让接收的那个合约再发起一个新的调用,只够写一个log而已

对应课程

北京大学肖臻老师《区块链技术与应用》公开课

猜你喜欢

转载自blog.csdn.net/qq_40378034/article/details/126570851