精通以太坊8:智能合约与Solidity(2)
8.1使用Solidity进行编程
有关Solidity的完整文档可以在这里访问:https://solidity.readthedocs.io/en/latest。
8.2数据类型:
布尔型(bool)
布尔值true或false,可以使用逻辑操作符!(否)、&&(与)、||(或)、==(等于)和!=(不等于)。
整数型(int、uint)
有符号(int)和无符号(uint)整数,以8比特为单位,从int8到uint256。当没有明确说明长度时,默认使用256比特,因为这也是以太坊虚拟机的字长。
固定浮点数(fixed、ufixed)
固定小数点的浮点数,使用(u)fixedMxN定义,其中M是比特的位数(从8到256), N用来表示小数点之后有多少位(最多18),例如ufixed32x2。
地址
20字节长度的以太坊地址。这个address对象有若干很有用的成员函数,最主要的就是balance,用来返回地址上以太币的余额,还有transfer,用来向地址发送以太币。
字节数组(固定的)
固定长度的字节数组,使用bytes1到bytes32进行声明。
字节数组(动态的)
动态长度的字节数组,使用bytes或string进行声明。
枚举
用户定义的数据类型,用于枚举互相没有关联的一组值,如enumNAME{LABEL1, LABEL 2, …}。
数组
数组可以包含任意类型的数据,可以是固定的,也可以是动态的。例如,uint32[][5]包含五个动态数组,成员为无符号整型。
结构
由用户定义的一种数据结构,其中包含多种其他类型的变量,如structNAME{TYPE1 VARIABLE1; TYPE2 VARIABLE2; …}。
映射
用于查找key=>value映射的哈希表,如mapping(KEY_TYPE=>VALUE_TYPE) NAME。除了上述数据类型,Solidity也提供了多种可用于计算不同单位的字面量。
时间单位
seconds、minutes、hours和days可以作为前缀,用来对基本的计时单位seconds进行单位转换。
以太币单位
wei、finney、szabo和ether可以作为前缀,对基本的以太币单位wei进行单位转换。
目前为止,在Faucet合约的例子中,我们使用uint(uint256的简称)来定义withdraw_amount变量。我们也在msg.sender中间接用到了address变量。我们会在后续的例子中使用更多的数据类型。现在让我们尝试使用单位转换工具,来改进合约例子中数值的可读性。在withdraw函数中我们限制了提取的最大值,使用wei这个以太币的最基本单位进行表示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mAaDzRbG-1585665609093)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585495503321.png)]
这并不是很容易读,所以我们可以使用单位转化工具来改善代码,使用以太币单位来表示,而不是用wei:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4lu1G1OU-1585665609095)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585495520663.png)]。,。
8.3预定义的全局变量和函数
当合约在EVM中执行时,它可以访问一组有限的全局对象,包括block、msg和tx对象。另外,以太坊把一些EVM字节码通过预定义函数的方式对外提供。在这一节,我们会讨论这些可以在智能合约中访问的以太坊全局变量和函数
交易/消息的调用上下文
msg对象是指触发合约执行的交易,这个交易可能来自外部账户交易调用,也可能来自其他合约的消息调用。这个对象包含了一些有用的属性:
msg.sender
我们已经使用过这个属性了。它代表发起合约调用的以太坊地址,这个地址不一定是发起调用的外部账户的地址。如果合约是由来自外部账户的交易触发的,那么这个地址就是对交易进行签名的地址,否则就是另外一个合约的地址(合约之间的互相调用)
msg.value
调用中发送的以太币数量(以wei为单位)。
msg.gas
执行环境中当前可用的gas数量。这个属性已经被废弃,将会被Solidityv0.4.21中的gasleft函数取代
msg.data
调用合约时传入的数据。
msg.sig
传输数据的前四个字节,这是一个函数选择器。
当一个合约调用另外一个合约时,msg对象的值将会全部发生变化,用以体现出新的调用方的信息。唯一的例外是delegatecall函数,它在当前合约的上下文中执行其他合约或者库的代码。
交易的上下文
tx对象提供了访问交易相关信息的办法:
tx.gasprice
调用交易的gas价格。
tx.origin
发起这个交易的外部账户的地址。警告:这是个不安全的做法。
区块的上下文
block对象包含了当前区块的信息
block.blockhash(blockNumber)
指定区块的哈希值,仅限于当前区块之前不超过256个区块。目前已经弃用,在Solidity v.0.4.22中被blockhash方法所取代。
block.coinbase
当前区块的矿工地址,用于接收这个区块的出块奖励和所有交易费。
block.difficulty
可以在当前区块中包含的所有事务中花费的最大gas数量。
block.number
当前的区块编号(区块在链中的高度)。
block.timestamp
由矿工写入的当前区块的时间戳,使用的是UNIX式的计时方式。
地址对象
不论是来自于外部输入,还是从合约对象中获取,地址类对象都包含如下属性和方法:
address.balance
当前地址的余额,以wei为单位。例如,当前合约账户的余额可以用如下方式来获得:address(this).balance。
address.transfer(amount)
转账一定数量(以wei为单位)的以太币到指定的地址,遇到任何错误都将抛出异常。我们在Faucet例子中使用过这个方法,针对的是msg.sender这个地址,即msg.sender.transfer
address.send(amount)
跟上面的transfer方法类似,但是在遇到错误时不会抛出异常,而是返回false。警告:需要总是检查send方法的返回值(确保转账成功)。
address.call(payload)
一种底层CALL函数,可以构建一个包含自定义数据的调用,出错时会返回false。警告:这不是一种安全的调用,调用接收方可以无意或有意地耗尽你的gas,导致合约因为00G异常而停止,总是要检查call的返回值。
address.callcode(payload)
一种底层DELEGATECALL函数,与callcode(…)相似,但是当前合约可以访问完整的msg上下文。在出错时会返回false。警告:仅限于特殊使用场合。
内建函数
其他值得注意的函数是:
addmod、mulmod
模数的加法和乘法,例如:addmod(x,y,k)计算(x+y)%k。
keccak256、sha256、sha3、ripemd160
多种算法的哈希计算函数
ecrecover
从数字签名的数据中计算,获取签名地址。
selfdestrunct(recipient_address)
删除当前的合约,把合约中剩余的比特币转账到recipient_address这个地址。
this
当前执行合约账户的以太坊地址。
8.4合约的定义
Solidity的核心数据类型是contract对象,在Faucet的例子中,代码一开始就定义了contract对象。类似于任何面向对象编程语言中的对象类型,合约对象也包含了数据和操作这些数据的方法。Solidity还提供了另外两个类似于合约的对象:
interface
接口的定义方式几乎跟合约完全相同,不过这些函数并没有具体的定义,只是做了声明而已。这些类型函数的声明通常被称为桩,因为它可以在不进行任何实现的情况下,告诉人们这些函数接收的参数和返回的值。这用于定义合约的接口,如果接口被继承,那么接口声明的所有函数必须在继承的子合约中定义。
library
库合约意味着只被部署一次,并供其他合约调用。使用delegatecall方法可以调用库合约。
8.5函数
在合约中,我们可以定义函数,函数可以被外部账户调用,也可以被其他合约调用。在Faucet的例子中,我们使用了两个函数:withdraw和(未命名的)回退函数。
函数通过如下语法定义:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B4KtEIKJ-1585665609097)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585658820702.png)]
我们来看其中的每个部分:
FunctionName
定义函数的名字,用于在外部账户发起的交易、其他合约或者本合约内部调用函数。合约中的回退函数在定义时不需要提供名字,这是在调用没有指明函数名字时默认会调用的方法。回退函数没有任何参数,也不返回任何值。
parameters
在函数名字之后,我们需要指定必须传递给函数的参数,包括参数的名称和类型。在Faucet的例子中,我们定义了uint withdraw_amount作为withdraw函数的唯一参数。
接下来的几个关键字(public、private、internal、external),用于设定函数的可见范围:
public
public是默认值,设定为public的函数可以被外部账户的交易、其他合约和本合约内部调用。在Faucet的例子中,两个函数都是被定义为public的。
external
定义为external的函数类似public函数,唯一的区别在于不能在合约内部调用,除非在调用时指明关键字this。
internal
internal函数只能被合约内部的函数调用,不能被外部账户的交易或者其他合约调用。它们还可以被派生的子合约调用(也就是继承了当前合约的子合约)。
private
private函数跟internal函数类似,唯一的区别是private函数无法被当前合约所派生出的子合约调用。
请注意,内部和私有这两个词会引起歧义。任何在合约内的数据或者函数都是在公共区块链上可见的,这也意味着任何人都能够看到代码或者数据。上述这两个关键字只是会影响函数是否可以被调用。
下面的一组关键字(pure、constant、view、payable)会影响函数的行为:
constant或view
当函数被标注为view时,它将承诺不对任何状态进行修改。constant是view的另一种表示方式,但是将被废弃不用。当前,编译器不会强制view修饰符,只是提出一个警告。但是在Solidity 0.5版本中,这会是一个强制的要求。
pure
pure函数表示这个函数不会在区块链存储中读取或者写入任何数据。这样的函数只能处理参数,然后返回值给调用方,无法在区块链上读取或者存储任何数据。pure函数旨在鼓励程序员编写声明式的代码,不产生副作用。
payable
payable函数用于接收外部的支付。未声明为payable的函数不能接收任何以太币支付。但是由于EVM的设计决策,有两种意外情况:区块挖矿的奖励支付和SELFDESTRUCT的合约注销支付将会有效,即使对应的回退函数没有声明为payable,这样的设定也是合理的,因为这类代码的执行并不属于那些支付流程。
如你在Faucet例子中所见,我们的代码包含payable函数(回退函数),这是合约中唯一可以用来接收以太币支付的函数。
8.6合约的构造函数和自毁函数
有一个特殊的函数,它只会被用到一次。当合约实例被创建时,如果构造函数这个函数已经定义,它就会被执行,用来初始化合约的状态。构造函数会运行在创建合约交易的上下文中。构造函数不是必需的,比如,Faucet的例子中就不包含构造函数。
构造函数可以通过两种方式定义。截止到Solidity v.0.4.21,构造函数是跟合约同名的函数:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7jT9HKEk-1585665609100)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585659966956.png)]
这种定义方式的问题在于,如果合约的名字发生了变化,但是构造函数没有及时改变,那么构造函数就失去作用了。同样,如果在输入合约或者构造函数的名字时不小心打错了字,那么构造函数同样不会起作用。这会导致一些非常难处理、无法预料甚至隐藏很深的问题。想象一下,例如,构造函数出于控制的目的而设置合约的所有者。如果由于命名错误而使该函数实际上不是构造函数,则不仅在创建合约时未能设置所有者,而且该函数也可以部署为合约的永久性和“可调用”部分,像普通函数一样,允许任何第三方劫持合约,并在合约创建后成为“所有者”。
为了解决构造函数定义中存在的这个潜在问题,Solidity v0.4.22引入了constructor关键字用于明确指定合约的构造函数。修改合约的名称不会影响构造函数,这样,也更容易识别到底哪个函数是整个合约的构造函数。具体的代码如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rMKHdOlm-1585665609102)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585660607344.png)]
所以,简而言之,合约的生命周期开始于来自外部的账户创建交易,或者合约账户的调用。如何合约定义了构造函数,就会在合约创建的同时执行,用于初始化合约中的各种内部状态,然后这个构造函数就再也不会被使用了。
合约生命周期的另一端是合约析构。合约被称为SELFDESTRUCT的特殊EVM字节码销毁。它过去被称为SUICIDE,但由于这个词比较负面,该名称已被废弃不用。在Solidity中,此字节码作为高级内置函数公开,称为selfdestruct,它接收一个参数:合同账户中剩余的任何以太币余额的地址。对应的代码如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6QDoYkN1-1585665609103)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585660973911.png)]
需要注意:程序员必须手动在合约中包含这个指令,这样合约才可以被销毁,这也是销毁合约的唯一途径。合约默认不提供这样的能力。如果用户希望合约永久存在,那么只要合约代码中没有加入SELFDESTRUCT字节码,这个合约就无法从以太坊中销毁。
8.7在Faucet合约中添加构造函数和自毁函数
我们在第2章中编写的Faucet合约并不包含任何构造函数或自毁函数。一旦部署之后,它将永远存在于以太坊的区块链上。我们做一些修改,添加构造函数或自毁函数。我们肯定希望只有部署这个合约的外部账户才能够调用自毁函数。根据约定,这个外部账户的地址会被保存在名为owner的变量中。我们在构造函数中设定owner变量的值,自毁函数在运行之前,会先检查调用方是否来自合约的owner。
首先来看构造函数的代码:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YZA7SHUG-1585665609104)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585661424826.png)]
们在一开始的编译器版本要求中指定v0.4.22是最低兼容的编译器版本,因为我们使用的构造函数关键字只有在v0.4.22或之后的Solidity编译器中才存在。合约中有一个address类型的变量owner。owner只是一个变量名称,在此没有任何特殊的含义,即使我们把这个变量称为“potato”,也仍旧不影响它的使用。变量的名称叫作owner只是为了明确用途,便于阅读
接着,构造函数会在合约被创建的交易上下文中运行,它会把创建合约这个交易的msg.sender赋予owner变量。我们曾经在withdraw函数中使用msg.sender来识别提币请求。在构造函数执行时,msg.sender是创建这个合约的外部账户的地址,或者是调用触发了合约创建的其他合约的地址。我们之所以这么肯定,是因为构造函数只会运行一次,并且肯定是运行在合约被创建时的那个交易上下文中。
好了,现在可以添加一个删除合约的函数,我们需要确保只有这个合约的所有者(部署合约的账户)才可以执行这个函数,需要使用require来控制对这个函数的访问。下面是具体的代码:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Wp72DB0W-1585665609108)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585663140943.png)]
如果调用destroy函数的地址并不等于owner中保存的地址,这次调用就会失败。如果调用的地址等于owner保存的地址,那么合约就会自我删除,并把合约账户中剩余的以太币发回给owner这个地址。需要注意:我们并没有使用不安全的tx.origin来判断调用合约的是否是合约的所有者,使用tx.origin会导致一些恶意合约在没有被授权的情况下删除你的合约。
8.8函数修饰符
Solidity提供了一种名为函数修饰符的特殊方法。在定义函数时,可以把修饰符添加到定义中。修饰符通常用来对函数进行某些条件限制。我们在destroy函数中曾有一个条件判断,现在通过修饰符来表达同样的条件:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PpTvcs5Y-1585665609112)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585663414466.png)]
在这段代码中,我们声明了一个名为onlyOwner的修饰符。它会对作用到的函数设定一个条件。在这个例子中,修饰符要求owner中保存的地址与当前调用交易的msg. sender一致。这是以太坊访问控制的最基本设计模式,即通过修饰符来限制只有合约的创建者才能够执行特定的函数。
你可能已经注意到了,在修饰符中有一个特殊的下划线和分号组成的占位符号“;”。这个占位符会被修饰符所作用到的代码替代。简单地说,这个修饰符会包裹在被修饰函数之外,把被修饰函数的代码放在这个下划线占位符的位置。
我们使用onlyOwner这个修饰符重新编写destroy函数:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TJrPGjc3-1585665609114)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585664081687.png)]
修饰符的名称(onlyOwner)位于public关键字之后,告诉编译器这个destroy函数已经被onlyOwner修饰符所修改。顾名思义,你可以认为,这个函数只能由合约的所有者调用。实践中,这等效于生成的代码与把onlyOwner的修饰符代码包裹在destroy之外。
修饰符是非常有用的工具,因为它允许我们为函数编写预制的条件,然后以非常一致的方式应用这些条件,让代码更容易阅读,也更容易审计,并且提升安全性。修饰符被广泛应用在函数的访问控制领域,但是修饰符的用法多种多样,也可以用于其他目的。
在修饰符的内部,你可以访问修饰符所作用到的函数能够访问的所有变量和参数。在这个例子中,我们可以访问owner,这个变量在合约中声明。但反过来却不行:你不可以在函数的代码中访问修饰符内定义的变量。
8.9合约的继承
Solidity的合约对象支持继承,这是扩展基础合约并添加额外功能的一种方式。为了使用继承功能,需要使用is关键字来指定父合约:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iJqi5IAt-1585665609115)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585664961683.png)]
在这个合约中,子合约Child继承了父合约Parent中的所有方法、功能和变量。Solidity也支持多重继承,在声明时,需要在is关键字之后使用逗号分隔每一个父合约:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6PsVVAj5-1585665609118)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585665003903.png)]
继承这个特性允许我们编写模块化、可扩展和可复用的合约。我们可以从一个简单的合约开始,其中只实现最基础的能力,然后通过继承的方式把合约的功能扩展到具体的领域。
我们从开始定义父合约owned开始,这个合约有一个在构造函数中定义的owner变量:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n2tznHDk-1585665609120)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585665125193.png)]
接着,我们定义一个名为mortal的父合约,它继承了owned:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QjBQvAbT-1585665609122)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585665162217.png)]
如你所见,mortal可以使用从owned继承而来的onlyOwner修饰符。它也间接地使用了owned的地址变量和owned中定义的构造函数。继承使得每一个合约都变得相对简单,能够聚焦在它所处理的具体问题上,这样我们就可以通过模块化的方式来管理所有细节。
现在我们可以更进一步扩展这个owned合约,在Faucet中继承和使用它的功能:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t6PqmXfo-1585665609124)(C:\Users\xiaoweifeng\AppData\Roaming\Typora\typora-user-images\1585665362193.png)]
通过继承mortal合约,以及间接继承的owned,Faucet合约现在有了构造函数和destroy函数,并且定义了合约所有者。这些功能与之前在Faucet内部通过代码实现的完全一样,但是我们却可以在其他合约中重复使用这些功能,而不用再编写重复的代码。代码复用和模块化让我们的项目更简洁、更易读,也更容易进行代码安全性审计
的地址变量和owned中定义的构造函数。继承使得每一个合约都变得相对简单,能够聚焦在它所处理的具体问题上,这样我们就可以通过模块化的方式来管理所有细节。
现在我们可以更进一步扩展这个owned合约,在Faucet中继承和使用它的功能:
[外链图片转存中…(img-t6PqmXfo-1585665609124)]
通过继承mortal合约,以及间接继承的owned,Faucet合约现在有了构造函数和destroy函数,并且定义了合约所有者。这些功能与之前在Faucet内部通过代码实现的完全一样,但是我们却可以在其他合约中重复使用这些功能,而不用再编写重复的代码。代码复用和模块化让我们的项目更简洁、更易读,也更容易进行代码安全性审计