北京大学肖臻老师《区块链技术与应用》ETH笔记 - 10.0 智能合约

10.0 智能合约

10.1 简介

智能合约:运行在区块链系统上的一段代码,代码逻辑定义了合约内容。

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

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

智能合约编写代码为Solidity,其语法与JavaScript很接近。下图是拍卖合约的代码例子,Solidity是面向对象的语言,这里的contract相当于C++的class类。Solidity是强类型语言,这里的类型和普通的编程语言像C++是比较接近的,比如uint是无符号整数。address是Solidity所特有的,后面会讲地址类型的成员变量和成员函数。

接下来是两个事件event,事件的作用是记录日志的,HighestBidIncreased是拍卖的最高出价,如果有人出了新的最高价,我们记录一下参数是address bidder出价人,金额是amount。Pay2Beneficiary 它的参数是winner最终拍下的人,amount金额。

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

再往下是它的构造函数,solidity语言定义构造函数有两种方法,一种是像C++一样定义一个contrast同名的函数,这个函数可以有参数但不可以有返回值。新版本(在这里是0.4.21)版本推荐的是图中的定义方式,用constructor定义,构造函数只有在合约创建的时候调用一次,构造函数只能有一个。

接下来是三个成员函数,这三个函数都是public,说明其他账户可以调用这些函数,示例中的函数都没有参数,注意bid()这个函数,它有一个标志叫payable,后面会解释什么意思。

10.2 账户调用

扫描二维码关注公众号,回复: 14664048 查看本文章

调用智能合约其实和转账是类似的,比如A发起一个交易转账给B,如果B是一个普通的账户,那么这就是一个普通的转账交易,如果B是一个合约账户的话,那相当于发起一次合约账户的调用。

10.2.1 外部账户调用合约账户

图例中sender address是发起调用的地址,to contract address 是被调用的合约的地址,调用的函数看(Tx DATA)这个地方给出了要调用的函数,如果这个函数有参数的话,参数的取值也是在这个DATA域中说明的。

中间这一行是调用的参数,VALUE是发起调用的时候转过去多少钱,GAS USED是我这个转账花了多少的汽油费,GAS PRICE是单位汽油的价格,GAS LIMIT是我这笔交易我最多愿意支付多少汽油,后面会详细讲汽油费的事情。

10.2.2 合约账户调用合约账户

合约账户之间也可以进行调用。其调用方式如下:

1、直接调用

A这个合约的foo函数的作用只是写条Log,LogCallFoo是一个事件,emit的作用是调用这个事件写一个Log,对于程序的运行逻辑是没有影响的。

B这个合约的函数是把A转化成一个实例,再去调用其中的foo这个函数。

错误处理:直接调用的方式,一方产生异常会导致另一方也进行回滚操作。

img

2、address类型的call()函数调用

addr.call(),图例中addr.call(funcsig,"call foo by func call")这个函数的第一个的参数funcsig是要调用的那个函数foo(string)的签名,第二个是要调用的参数"call foo by func call"。

错误处理:address.call()的方法,如果调用过程中被调用合约产生异常,会导致call()返回false,但发起调用的函数不会抛出异常,而是继续执行。

3、代理调用delegatecall()

和call()调用基本一致,区别在于其并不会切入被调用合约的上下文中。

10.2.3 关于Payable

凡是要接受外部转账的函数都必须标志成payable,否则你给这个函数转去钱的话会引发错误处理,会抛出异常。如果不需要外部转账这个函数就不需要写成payable。

如下,成员函数中的第一个函数bid(),有一个payable修饰。该例中背景为拍卖,bid()为出价,你调用这个函数的时候要把这个出价对应的币也转过去,存储到合约里,锁定在那里一直到合约结束,因此需要payable进行标记。

withdraw()为其他未拍卖到的人将锁定在智能合约中的钱取出的函数,其不涉及转账,因此不需要payable进行标记。

10.2.4 fallback()函数

这个函数没有名字,没有参数,也没有返回值。注意,fallback这个关键字不出现在这个函数名中间。fallback中文其中有个意思是“应急计划”,当没有什么函数可以调用的时候就调用它。比如A向B转账,但没有在data域中说明要调用哪个函数,或说明的要调用函数不存在,此时调用fallback()函数。这也是为什么这个函数没有参数,因为本来就没法提供参数。

fallback()函数也同样可以标注成payable,一般情况下都会写成payable。因为一个合约其他函数没有标志成payable,fallback()函数也没有标志成payable,那它没有任何能接受转账的能力。如果向这个合约转账,data域为空,调用fallback()函数,会抛出异常。

另:转账金额和汽油费是不同的。汽油费是为了让矿工打包该交易,而转账金额是单纯为了转账,其可以为0,但汽油费必须给。

10.3 智能合约创建与运行

智能合约的创建是由某个外部账户发起一个转账交易到0x0地址,转账金额是0,把要发布的代码放到data域里面。EVM设计思想类似于JAVA中的JVM,便于跨平台增强可移植性,通过加一层虚拟机,对智能合约的运行提供一个一致性的平台。所以EVM有时叫做world wide computer。EVM中寻址空间256位,像之前我们看到的uint就是256位的,个人PC一般是32或者64位。

10.4 汽油费

比特币的设计理念是简单,脚本语言很有限,比如说不支持循环,而以太坊要提供一个图灵完备的编程模型(Turing-complete Programming Model),很多功能在比特币平台上实现起来很困难,甚至是实现不了的,但到了以太坊上就很容易。但这也导致一些问题,出现了死循环怎么办?当一个全节点收到一个对智能合约调用,怎么知晓其是否会导致死循环。

事实上,无法预知其是否会导致死循环,实际上,该问题是一个停机问题(Halting Problem),停机问题是不可解的,从理论上可以证明不存在一个算法,能够对任意给定的输入程序判断出这个程序是否会停机。因此,以太坊引入汽油费机制将该问题扔给了发起交易的账户,遏制发起交易的人滥用资源。第二个目的是给矿工消耗的计算资源的一些补偿。注意这里的问题不是NPC的,NPC的问题是可解的,只不过它没有多项式时间的解法,很多NPC问题有指数时间的解法,比如说哈密尔顿回路问题,判断一个图有没有哈密尔顿回路,这个其实是很容易解的,如果不考虑复杂度的话,想到一个解法是很容易的,比如可以把所有的可能性枚举一遍。

我们来看下交易的数据结构txdata。AccountNonce是交易序号,用于防止第2章说的重放攻击(reply attack)。Price和GasLimit就是和汽油费相关的,GasLimit是我这个交易愿意支付的最大汽油量,Price是单位汽油的价格,这两个乘在一起是这笔交易可能消耗的最大汽油费。Recipient是收款人的地址。Amount是转账金额,把Amount这么多的钱转给Recipient。从代码中可以看到转账金额和汽油费是分开的。

当一个全节点收到一个对智能合约的调用,先按照GasLimit算出可能花掉的最大汽油费,然后一次从发起调用的这个账户上把钱扣掉,再根据实际执行情况,算出实际花掉的汽油费,多出来的部分会退掉,但如果给少了会发生回滚。EVM中不同指令消耗的汽油费是不一样的,简单的指令很便宜(比如加法减法),复杂的(比如取哈希)或者需要存储状态的指令就很贵,如果只是需要读取公共数据那些指令可以是免费的。

在一个区块头(Block Header)中有GasLimit和GasUsed两个域。GasUsed是这个区块中所有交易中所消耗的汽油费加在一起。但是,GasLimit不是这个区块中所有交易中的GasLimit加在一起。下面来说说这个的用意。发布区块需要消耗一定的资源,那要不要对这个区块所消耗的资源有一个限制,比特币中对发布的区块也是有一个限制的是大小的限制,最多不能超过1M。因为如果一个区块没有任何的限制,那一个矿工可能把很多的交易打包成一个区块发布出去,那这个超大的区块在区块链上会消耗很多的资源。比特币的交易较为简单,基本上可以用这个交易中所包含的字节数看出消耗的资源是多少,但是在以太坊中不一样,有些交易在字节数上看起来是很小的,但是消耗的资源很大,比如它可能调用别的合约之类的。所以要根据交易的具体操作来收费,这个就是汽油费。所以在区块头中的GasLimit是这个区块中所有交易能消耗的汽油费的一个上限。

相对于比特币中写死的1M大小的区块容量上限,以太坊中的这个GasLimit的这个上限是浮动的,矿工可以自己进行调整,可以在上一个区块的GasLimit的基础上,上调或者下调1/1024的GasLimit。不要觉得1/1024很小,由于出块速度快,如果每个区块都选择上调,那很快就能翻一翻。有的矿工觉得要上调,有的矿工觉得要下调,在这种机制下,得出的GasLimit是所有矿工觉得合理的GasLimit的一个平均值。

汽油费是怎么扣掉的?

全节点收到调用智能合约的要求的时候,从本地维护的状态树中把对应的账户余额减掉,如果GasLimit有剩余的再把余额加上去一点点。智能合约的执行都是在改本地的数据结构,只有获得记账权之后才可以把本地的状态同步出去,变成区块链上的共识。别的没有获得记账权的节点,需要把自己刚做的候选区块的交易给扔掉,把获得记账权的节点发布的区块信息在本地重新执行一遍,更新自己本地的三棵树。

10.5 错误处理

以太坊中交易具有原子性,要么全执行,要么全不执行,不会只执行一部分,这个交易包含普通的转账交易,也包含对智能合约的调用。所有如果在执行智能合约的过程当中,出现任何错误会导致整个交易的执行回滚,退回到开始执行之前的状态,就好像这个交易就完全没有执行过。什么情况下会出现错误?一种是汽油提供的不够,Gaslimit用完了,已经消耗掉的汽油费是不会退回的,不然会有恶意节点反复去调用计算量很大的合约,但汽油费给的不够,如果汽油费还能退回来,对攻击者没有任何损失,对矿工来说白白浪费了很多资源。另一种遇到异常的情况是,遇到了assert()、require()或者revert()语句,assert()、require()这两条语句都是用来判断某种条件,如果条件不满足就会抛出异常。assert()类似C++,处理内部错误,require()处理外部错误,比如拍卖合约中的require(now <= auctionEnd),如果符合条件就继续运行,如果拍卖都已经结束了,你还在出价,就会抛出异常。revert()是执行到这个语句就会发生回滚。Solidity里面没有类似Java可以自定义的try-catch()结构。

嵌套调用:一个合约调用另一个合约中的函数。

嵌套调用是否发生回滚,取决于调用方式。如果是直接调用的方式会出现连锁式的回滚,如果是call()的方式就不会引起连锁式的回滚,只会使当前的调用失败返回一个false的返回值。需要注意的是,有些情况下从表面上来看你没有调用任何一个函数,比如一个合约向一个合约账户直接转账,没有调用任何函数,因为fallback函数的存在,就有会调用到fallback函数,这个也叫嵌套调用。

10.6 一些问答

1、是先挖矿还是先执行交易和智能合约?

先执行交易内容和智能合约,更新完三颗树得到根哈希值,这样区块头的内容才能确定,然后才能挖矿尝试各个nonce。

2、会不会有全节点收到新的区块,不在本地重新验证一遍,直接继续挖?

不行,如果这个节点跳过了验证的步骤,那它的三棵树的状态就无法更新,无法继续挖矿了。发布的区块里面可没有三棵树状态的内容,不能直接拿过来,只能自己去算。

3、发布到区块链中的交易是不是都是成功执行的?如果智能合约执行出现了错误要不要也发布到区块链上面去?

不一定都是成功执行的,执行错误的结果也要发到区块链上,否则汽油费扣不掉。怎么知道一个交易是不是执行成功了呢?每个交易执行完成后形成一个收据,其中status这个域是记录交易执行的情况。

4、智能合约支持多线程吗?

solidity不支持多线程,因为以太坊是交易驱动的状态机,这个状态需要完全确定的。多线程的问题在于多个核对内存访问的顺序不同的话,执行的结果可能是不一样的。除了多线程外其他造成执行结果不一致的行为还有随机数等。

5、智能合约能获得的信息:

智能合约的执行必须是确定性的,所以它不能通过像系统调用那样获得环境信息,因为每个全节点执行的环境很可能是不一样的。所以只能得到一些固定的状态信息。

智能合约可以获得的区块信息:

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

msg.sender和tx.origin的区别:msg.sender指的是调用当前合约的发起方,可以是合约账户。但是tx.origin指的是最初发起整个交易的账户。 msg.gas是当前的调用还剩余多少汽油费,这决定了我还能做哪些操作,包括你还想调用其他合约前提是还有足够的汽油费剩下来。 msg.data是所谓的DATA数据域,调用了哪些函数和函数参数的取值。 msg.sig是数据域的前四个字节,函数标志符。 now是当前区块的时间戳和block.timestamp是一个意思。智能合约无法获得很精确的时间,只能获得当前区块的时间。

10.7 地址类型

<address>.balance:是成员变量,剩下的都是成员函数。uint256是成员变量的类型,不是函数调用。
<address>.transfer():转账,是指当前合约往address这个地址转入多少钱。address是转入地址,不是转出地址。转出地址是当前使用这个函数的合约。
<address>.call():调用,是当前的合约去调用address这个合约。同样的发起方是合约,接收方是address。
<address>.delegatecall():调用,不需要切换到被调用的函数环境中,使用当前合约的余额、存储这些状态去运行就可以了。

如果转账的时候,对方没有定义fallback函数所引起的错误会不会造成连锁回滚,这取决于是怎么转账的。(和调用会不会造成回滚类似,取决于是怎么调用的。)

转账有三种方式:1、transfer()会导致连锁性回滚,类似于直接调用。2、send()有返回值,不会导致连锁性回滚。3、call.value(uint256 amount)(),第一个参数填转账金额,第二个如果不调用函数可以是空的,不会导致连锁性回滚。transfer和send就是发送转账用的,call.value本来是函数调用的,也可以用来转账。transfer和send只能发一点点汽油2300个单位,收到调用的合约基本上干不了什么事,只能写写日志,call.value是把当前发起调用的合约剩余所有的汽油全部发过去,对方用完之后返回来还能接着运行。

10.8 智能合约的例子--拍卖

10.8.1 例子的代码详解

拍卖的规则: 拍卖有个受益人beneficiary,也就是实际卖东西的人。auctionEnd,拍卖的结束时间。highestBidder,最高出价的人。拍卖结束之前每个人都可以去出价,竞拍的时候为了保证诚信,需要把出价的ETH转账过去,锁在里面,直到拍卖结束。拍卖过程中不运行中途退出。拍卖结束后最高出价人的钱将转给受益人,其他没有竞拍成功的人,可以把钱取回来。竞拍是可以多次出价的,中间加价只需要补差价就可以。出价要有效的话,要比最高出价的要高才行。

constructor构造函数会记录下收益人是谁,结束时间。

这里是拍卖要用的两个函数,bid()和auctionEnd()。

bid()函数是竞拍的时候用的,你要竞拍就发起一个交易调用合约中的bid()函数,bid()函数并没有参数,一般来说,你参与交易需要告诉对方你出的价格是多少,那这个价格从哪里体现了呢。在msg.value里面,这个是随交易发送过去的wei的数量。代码详解如下:

首先先检查拍卖是否结束了,require(now <= auctionEnd);

再检查这次出价和之前所有的出价之和是不是高于最高出价者,require(bids[msg.sender]+msg.value > bids[highestBidder])。如果没有出过价钱,bids[msg.sender]是个哈希表,哈希表查询不到时会返回0。

如果bids[msg.sender]等于0,说明此人之前没有出过价钱,需要把拍卖者的信息msg.sender加到bidders[]数组里。因为Solidity的哈希表不支持遍历,你要遍历哈希表的话你要保存一下他包含哪些元素。

highestBidder=msg.sender记录下新的出价人是谁。bids[msg.sender] += msg.value更新最新的出价。

emit HighestBidIncreased(msg.sender, bids[msg.senger])。写条日志,更新下最高的出价人和出的价钱。

auctionEnd()是拍卖结束后的函数,代码详细解释如下:

首先查一下拍卖是不是已经结束了,require(now > auctionEnd);

然后判断一下这个函数是不是已经被调过了,require(!ended),如果被调过了就不用再调一遍了。

beneficiary.transfer(bids[highestBidder]);把最高出价人所对应的出的最高的金额转账给受益人。

对于没有竞拍成功的人,用一个循环把他们出过的价钱退回给bidder,bidder.transfer(bids[bidder]);

最后标注一下这个函数已经执行完了,写一个AuctionEnded竞拍结束的日志。

10.8.2 合约的安全

以上的合约其实有安全漏洞。看以下的黑客合约hackV1,当然这是一个合约账户,黑客需要从外部账户中去调用这个合约中的hack_bid函数,然后这个函数再去调用拍卖合约中的bid()函数,把黑客的外部账户的钱转到黑客合约中再转到拍卖合约中的bid()函数,来参与拍卖。

hack_bid(address addr)参数是拍卖合约的地址,转成拍卖合约的一个实例,调用拍卖合约的bid()函数,把钱发送过去。好像这样也没什么问题,但是最后退款会有问题,我们来看退款的函数。

退款到黑客合约的钱会有什么情况?bidder.transfer()转账的时候不会调用黑客合约的任何一个函数,所有会去调用黑客合约的fallback()函数,但是黑客合约也没有定义fallback()函数。所以,转账会抛出异常,transfer()函数会引起连锁式的回滚,整个参与拍卖的人都会收不到钱。对黑客来说,可能只是为了捣乱没写fallback()函数,也可能是忘记写fallback()函数。

注意,在给每个bidder转账退款的时候,是全节点去修改本地的数据结构,并不是形成一条条新的交易到区块链上。

所以现实中出现这种情况怎么办,答案是没有办法,黑客合约已经参与了竞拍,合约内容无法改,拍卖合约同样无法改。Code is law,代码就是法律。没有人能篡改规则,规则有了漏洞也无法改了。

能在合约中留个后门给创建者超级用户的权力吗,比如在拍卖合约的构造函数里添加一个owner,记录owner的地址,允许它做类似系统管理员的操作,比如可以任意转账把钱转到哪个地址都行。但这个和去中心化的理念是背道而驰的。

下面是拍卖合约的第二个版本,由竞拍者自己取回钱。

withdraw():先检查拍卖是否结束,再检查这个人是否是最高出价者,如果是的话不能给他,因为他的钱要给受益人。然后再看这个人的账户的余额是不是正的,把这个人的账户余额转给msg.sender这个人,再把他的账户余额归0,免的他下次再来取一次钱。

pay2Beneficiary():把最高出价给受益人。

但这样改也是有问题的,因为addr.call.value()()会触发addr里面的fallback()函数,而黑客可以在自己的黑客合约中的fallback()函数中做手脚。如下图所示。

黑客在fallback()函数中又调用了拍卖合约的withdraw()把钱取了一遍。但是在拍卖合约中withdraw()函数对账户余额的清零操作是在交易完成之后才开始。而转账的语句已经陷入到了与黑客合约的递归调用之中,根本执行不到下面清零的操作。

递归到什么时候结束?1、拍卖合约的余额不够了。2、汽油费不够了,每次递归调用要消耗汽油费的。3、调用栈溢出了。所以在黑客合约里有这样的判断条件,当前的拍卖合约的余额大于出价(即还能支持转账),当前调用的gas剩余是大于6000的,调用栈小于500。

怎么修改?最简单的办法是先清零再转账,先把bids[msg.sender]=0。就像pay2Beneficiary()函数,把最高出价者的余额归零,bids[highestBidder]通过哈希表修改余额,然后再转账,如果转账失败了,再把bids[highestBidder]的余额恢复。

以上是和其他合约的经典编程模式,先要判断条件,然后再改变条件,最后再和别的合约发生交互。在区块链上任何未知的合约都可以是有恶意的,所以每次需要转账或者调用别的合约的时候都要提醒自己,这个合约有可能会反过来调用自己当前的合约,并且修改状态。

还有一种修改的办法,就是不要使用call.value的方式,用send()或者transfer(),因为这两个函数转账发送过去的汽油费只有2300个单位,这个不足以让接受的合约再发起一个新的调用。

猜你喜欢

转载自blog.csdn.net/qq_40503872/article/details/124199565
今日推荐