简介

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

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

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

智能合约编写代码为Solidity,其语法与JavaScript很接近。

下图为一个简单的拍卖交易的智能合约:

image-20230118144902925

账户调用

外部账户调用合约账户

image-20230118145530648

合约账户调用合约账户

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

  • 直接调用

image-20230118150023213

  • 使用address类型的call函数

image-20230118150318439

上面两种调用方式的错误处理是不同的,比如B调用了A,而A发生了错误,前者A、B会一起回滚;而后者只会回滚A,因为B这时候会返回false,代表A发生了错误,并且调用的A的函数不会有返回值,所以只需要回滚A即可。

  • 代理调用

fallback()函数是在智能合约定义的,也可以不定义;代表一种缺省调用,我们上面提到调用智能合约的函数以及参数会写在data域里,如果向一个智能合约转账而data域为空,或者data域中要调用的函数不存在,那么就会调用fallback函数

image-20230118150805136

注意:上面提到的函数、代码都是智能合约才有的,外部账户没有代码

智能合约的创建与运行

image-20230118151501047

汽油费

智能合约是一个图灵完备的编程模型,可以执行很多复杂操作,比如循环,而比特币系统只能支持一些简单的指令。那么如果出现死循环怎么半?首先我们能不能判断它是否出现了死循环,也就是停机问题Halting problem,答案是否定的,我们没法判断是否出现了死循环。那么智能合约的解决方案就是把问题抛给发起交易的人(支付汽油费)。

不同指令花费的汽油费是不同的,比如加减操作花费较少,而取哈希花费的较多,一些读操作是免费的

image-20230118152338069

当一个全节点收到一个对智能合约的调用,先按照预估的汽油费收取,从其账户一次性扣除,再根据实际执行情况,多收了回退回账户而少了会引发回滚,返回到执行合约之前的状态,但是汽油费不退还:防止恶意节点故意执行一些计算量大的操作来影响其他矿工。

image-20230118155313889

上图是我们之前看过的block header的数据结构,我们观察到有GasLimit和GasUsed。GasUsed为这个区块使用的汽油费合计,GasLimit为区块限制的汽油费上限。实际上,块头里的gaslimit并非将所有包含的交易里说明的gaslimit相加,而是该区块中所有交易能够消耗的资源的上限。通过收取汽油费保障系统中不会存在对资源消耗特别大的调用。但与比特币不同,比特币直接通过限制区块大小1MB保障对网络资源压力不会过大(因为比特币交易比较简单,字节数就可以代表操作所需的资源大小),这1MB大小是固定的,无法修改。而以太坊中,每个矿工都可以以前一个区块中gaslimt为基数,进行上调或下调1/1024,通过绝大多数区块不断上下调整,保证得到一个较为理想化的gaslimt值。最终整个系统的gaslimt就是所有矿工希望的平均值。

为什么要引入汽油费?

在比特币系统中,交易是比较简单的,仅仅是转账操作,也就是说可以通过交易的字节数衡量出交易所需要消耗的资源多少。但以太坊中引入了智能合约,而智能合约逻辑很复杂,其字节数与消耗资源数并无关联。存在某些交易,从字节数来看很小,但其实际消耗资源很大(例如调用其他合约等),因此要根据交易的具体操作收费,所有引入了汽油费这一概念。

错误处理

image-20230118153749783

image-20230118154109578

挖矿与智能合约执行

假设全节点要打包一些交易到区块中,其中存在某些交易是对智能合约的调用。全节点应该先执行智能合约再挖矿,还是先挖矿获得记账权后执行智能合约?

先挖矿后执行智能合约。如果先执行智能合约,后挖矿,可能导致同一智能合约被不同节点执行多次,每次执行智能合约都会扣除汽油费,那么会导致汽油费被扣多次

这种观点是错误的。首先,我们要知道汽油费是怎么扣除的。我们之前提到了以太坊中“三棵树”——状态树、交易树、收据树。这三颗树存储在每个全节点中,是全节点在本地维护的数据结构。所以所有的全节点收到交易,都会执行验证交易的合法性,并且修改本地三棵树的状态,所以转账操作,包括我们提到的汽油费的预扣除,都是全节点在本地的修改,也就是修改三棵树的状态。当一个全节点挖出矿并发布到区块链上时,所有的全节点都会回滚到之前的状态,然后执行这个新发布的区块的交易,也就是重新执行智能合约,然后更新本地的状态,并与块头信息比对,确认无误才修改本地的值,也就是承认新发布区块的合法性。

所以上述汽油费扣多次的顾虑是多余的,真正的原因是:你如果想获取汽油费,那就必须先执行智能合约,再挖矿;因为我们上面有一张图有block header的数据结构,他其中有Root、TxHash、ReceiptHash分别代表三棵树的根哈希值。如果你不执行智能合约,那么就代表你不想把包含智能合约的交易写入你当前挖的区块中,所以不存在先挖矿,等挖完矿在执行智能合约的说法。

一些问题

发布到区块的交易都是成功执行的么?

不是的。因为我们提到有些恶意节点发布的超出交易定义的GasLimit的交易,这种交易预收的汽油费是不退还的,而这些交易不是成功执行的,但我们必须把这些交易包含进去,不然没法扣除汽油费,矿工也没法收到汽油费。

我们上面提到,全节点都要执行智能合约,然后才能开始挖矿,那么如果我花了时间执行了智能合约,但最后我没有挖到矿,那么我该接着挖,还是转到最新区块上去挖

实际上转到最新区块上挖更合适。首先,挖矿是无记忆性的,无论前面挖了多长时间,挖到矿的可能性都是一样的。其次接着挖,就是奔着叔父区块的奖励,而叔父区块的奖励是不包含汽油费的,并且比新区块的奖励少。综上,转到最新区块上合适。

挖到矿的只有一个全节点,那些没有挖到矿的全节点在接收到一个新的区块的时候,要回滚到上一个区块的状态,然后把最新区块的包含的交易执行,并更新本地的数据,并验证当前区块的合法性。那么我还要费力再次执行智能合约,这在以太坊里没有任何补偿,那么我可不可以偷懒不验证,默认它是合法的区块,然后接着挖?

这样也是不行的。因为你执行区块的交易,那么你本地的三棵树就没法更新,而块头含有三棵树的根哈希值,你如果按照现在本地的状态挖矿,那么其他全节点收到了一定会认为你挖出来的区块是非法的,你就永远丧失了挖到矿的可能性。换句话说,全节点有挖矿的权利,相应的也必须承担维护系统一致性的义务

智能合约支持多线程吗?

不支持,根本就没有支持多线程的语句。因为以太坊本质为一个交易驱动的状态机,面对同一组输入,必须转移到一个确定的状态。但对于多线程来说,可能因为不同线程访问内存的顺序不同,导致最终的结果不一致。
此外,其他可能导致执行结果不确定的操作也不支持,例如:产生随机数。因此,以太坊中的随机数是伪随机数。

也正是因为其不支持多线程,所以不能像其他编程语言一样通过系统调用获得系统信息,因为每个全节点的执行环境并非完全一样。因此只能通过固定的结构获取变量的值。下图分别为为其可以获得的区块链信息和调用信息。

image-20230118194423157

image-20230118194953758

Receipt数据结构

image-20230118195137573

以太坊中的地址类型

需要注意的是address.transfer(5000)代表的是向address这个地址对应账户转5000wei,而不是我们在其他编程语言中的那样:address这个地址的账户转出5000wei。实际上是一个合约调用address.transfer(5000),代表这个合约对应的账户给address对应账户转5000wei。

image-20230118201508423

以太坊的转账方式

在这里插入图片描述

有以上三种:其中transfer失败会抛出异常,也就是会导致连锁回滚,因为它执行失败会抛出异常。send不会导致连锁回滚,因为它执行失败会返回false。另外,也可以用调用函数call来进行转账,它与上面两种专门用来转账的函数相比,区别在于,后者只需要2300汽油费,这点汽油费很少,只能写一个log,而call的方式则是将自己还剩下的所有汽油费全部发送过去(合约调用合约时常用call,没用完的汽油费会退回)。

拍卖行实例:

我们回到本节刚开始的拍卖行的例子:

简单的规则:有一个受益人,比如A要拍卖一个古董,A就是受益人。然后其他的人想要拍卖这个古董,你想出100个以太币,那么你就把这100个以太币转给智能合约,这100个以太币就会锁在智能合约里,直到拍卖结束(不允许中途退出,这100个以太币只能拍卖结束才能解除锁定)。拍卖结束时,出价最高的人的以太币会给受益人,没有竞拍成功的人可以把钱取回来。竞拍是可以多次出价的,多次出价只需要补差价即可。有效出价指的是你的出价比当前的最高出价高。否则就是非法的

image-20230118204343543

下面是出价函数bid()和拍卖结束函数auctionEnd()

首先我们看到bid()函数是没有参数的,按理说出价人出价应该说明自己的地址和出价,我们之前提到过,外部账户调用合约账户的bid()函数中存在一系列参数,图中框出来的msg.sender和msg.value就是调用过程中的参数,代表出价人地址和出价。

image-20230118210335176

问题

整个拍卖过程到底是什么样的,包括拍卖怎么发布,外部账户怎么出价?

实际上,我们之前提到,智能合约的创建是外部账户发起一个转账交易到0x0,那么实际上就是把上面提到的拍卖的全部代码放到data域中,然后宣传由自己完成,就是以太坊负责接受你发布的拍卖,创建一个合约账户,而如何让其他人知道你的拍卖,是你自己的事。

外部账户出价,指的是外部账户通过受益人的宣传得知了拍卖这个智能合约的地址,发送交易给这个智能合约,调用bid函数,并转入一定数量的以太币作为出价。

事实上,你写入的智能合约会永久的存在以太坊的状态树中,然后每时每刻外部账户都可以发起交易调用bid()函数来出价(因为平均出块时间15s,所以可能跨越很多个区块),你出价了,并且被写入到区块链中,那么你出价的那部分以太币就相当于锁定在了智能合约里。

看起来好像只有执行了auctionEnd()函数才能把钱退回去,那么这个函数可不可能会被多次执行?就是一个人执行到for循环还没有修改ended的值,这时候又有一个人调用这个函数,会不会退两份钱

实际上是不会的,上面提到的问题实际上是并发执行,但矿工执行不会并发执行。我们说过,全节点收到交易修改的是本地的数据结构,那么如果有两个人先后调用了这个函数,那么全节点在验证函数合法性的时候,在验证完先收到的函数之后,后收到的会直接判断非法,因为第一次他已经改了ended的值了

但确实是只有执行了auctionEnd()函数才能把钱退回去,这也是Solidity跟其他编程语言不同的地方,不能拍卖结束自动退回账户

最大的问题

如下图,假设黑客创建了一个合约账户hackV1,然后通过调用智能合约hackV1的hack_bid()函数来出价,那么出价是没有问题的,当执行到红框中的退款时,执行到退还给hackV1这个账户时,转账执行时没有调用任何函数,那么缺省条件下应该调用fallback函数,而hackV1故意没有定义fallback函数,那么就会调用失败,而transfer调用失败会抛出异常并且会连锁回滚。这个回滚会回滚到交易执行前,也就是auctionEnd()执行前,就好像这个智能合约没有执行过,那么所有出价者以及受益人都没法得到钱,并且受益人还得把拍卖的东西发给最高出价者。可能是有人故意攻击,也可能是有的人不知道要写fallback函数,总之这种攻击是可能存在的。

image-20230118214329423

那我们所有人的钱都锁在了智能合约中,那该怎么取出来?

你可能会说,以后设计智能合约时,留个后门,在里面加个管理员权限的东西,使用管理员权限能进行各种操作,也就能取出来钱。但是这跟区块链的去中心化思想相违背,这相当于加了个中心化机构,这是不合适的。

所以这钱永远取不出来。有一种说法就是code is law。智能合约的规则是由代码逻辑决定的,而智能合约一旦发布到区块链中,由于区块链的不可篡改性,便不可修改。这样的好处是:没有人能篡改规则;坏处是:有bug不能修。

智能合约如果设计的不好,那么可能有一部分以太币会被永久锁定,谁也取不出来,就像我们上面的例子。现实中还有智能合约“锁汤”,就是开发者开发过程中,预留一部分币,用智能合约锁三年,这样大家可以集中精力开发,但如果多加了一个0,锁了30年,那谁也没办法取出来。

所以在发布一个智能合约之前,一定要测试测试再测试。在专门的test net上用假的以太币测试,确认完全没有问题才能发布!

改进

我们改进为投标者自己取回出价

image-20230118221500362

问题

可能会遭受重入攻击

如下图HackV2为攻击者编写的智能合约,合约账户收到以太币但未调用函数时,会执行fallback()函数,通过addr.send()、addr.transfer()、addr.call.val()三种方式付钱也会进入addr里的fallback()函数,所以在第一次执行withdraw进行到if语句时,会直接跳转到HackV2的fallback()函数中,而这个函数会反复调用拍卖合约中的withdraw()函数,结果就是一直从这个智能合约里取钱,那这个取钱不是无限的,因为智能合约里的钱有限,第二次以及以后的取钱取得实际上是其他投标者的钱,所以当汽油费不够或者栈溢出或者智能合约里的钱不够msg.value时会报错,而他自定义的fallback()函数设定了三种循环停止的条件,会持续取钱到取不出来为止,还不会报错。

image-20230118221911685

他为什么会能重复取钱?

bids[msg.sender] = 0的执行在转账之后,也就是先转账,在清零。应该向受益人收钱一样,先清零,再转账。这个实际上是可能和其他合约产生交互的情况的一种经典的编程模式:先判断条件,然后改变条件,最后跟其他合约产生交互。在区块链上,任何合约都可能是恶意的。其次使用了call()调用,我们前面提到call()会把当前剩余的所有汽油费都给被调用方,就给了他执行复杂操作的可能,所以可以使用tranfer()或者send(),这两种转账方式只会给2300汽油费,只够打一个日志,不足以干其他的事。

修改后如下图

image-20230118223322315