《精通以太坊》-智能合约
正如我们在[intro]中看到的那样,以太坊中有两种不同类型的帐户:外部拥有帐户(EOA)和合约帐户。EOA由以太坊外部的软件控制,例如钱包应用程序。合约帐户由在以太坊虚拟机(EVM)中运行的软件控制。两种类型的帐户都由以太坊地址标识。在本节中,我们将讨论第二种类型,合约账户以及控制它们的软件:智能合约。
什么是智能合约?
智能合约这个术语被用来描述各种不同的东西。在20世纪90年代,密码学家Nick Szabo创造了这个术语,并将其定义为“一套以数字形式指定的承诺,包括各方履行其他承诺的协议。”从那时起,智能合约的概念已经发生变化,尤其是在2009年通过比特币的发明引入去中心化的区块链之后。在本书中,我们使用术语“智能合约”来指代在以太坊虚拟机(作为分散的世界计算机运行)的上下文中确定性运行的不可变计算机程序。
让我们解开这个定义:
计算机程序:智能合约只是计算机程序。合约一词在这方面没有法律意义。不可变:一旦部署,智能合约的代码就无法改变。与传统软件不同,修改智能合约的唯一方法是部署新实例。确定性:智能合约的结果对于运行它的每个人来说都是相同的,在调用它的交易的上下文中,以及在执行时的以太坊区块链的状态。EVM上下文:智能合约以非常有限的执行上下文运行。他们可以访问自己的状态,调用它们的交易的上下文以及有关最新区块的一些信息。分散的世界计算机:EVM作为每个以太坊节点上的本地实例运行。
智能合约生命周期
智能合约通常用高级语言编写,例如Solidity。但是为了运行,必须将它们编译为在EVM中运行的低级字节码(参见[evm])。编译完成后,它们将部署在以太坊区块链上,并与特殊合约创建地址进行交易。每个合约都由以太坊地址标识,该地址是作为始发帐户和随机数的函数从合约创建交易中获得的。合约的以太坊地址可以作为收件人在交易中使用,将资金发送给合约或调用合约的一个功能。
重要的是,合约只有在交易调用时才会运行。以太坊中的所有智能合约均由外部拥有账户发起的交易执行。合约可以调用另一个可以调用另一个合约的合约,但是这样的执行链中的第一个合约必须始终由EOA的交易调用。合约从不“自行”运行,或“在后台运行”。合约实际上在区块链上处于“休眠”状态,直到交易触发执行,直接或间接作为合约调用链的一部分。
无论他们调用多少合约或这些合约在调用时做什么,交易都是原子的。交易完全执行,只有在交易成功终止时才会记录全局状态(合约,帐户等)中的任何更改。成功终止意味着程序在没有错误的情况下执行并且到达执行结束。如果交易由于错误而失败,则其所有影响(状态更改)都“回滚”,就像交易从未运行一样。失败的交易仍存储在区块链中,并从原始账户中扣除燃气费用,但对合约或账户状态没有其他影响。
合约的代码无法更改。但是,可以“删除”合约,从区块链中删除代码及其内部状态(变量)。要删除合约,请执行名为SELFDESTRUCT(以前称为SUICIDE)的EVM操作码,该操作码将从区块链中删除合约。该操作花费“negative gas”,从而激励储存状态的释放。以这种方式删除合约不会删除合约的交易历史(过去),因为区块链本身是不可变的。但它确实从所有未来的区块中删除了合约状态。
以太坊高级语言简介
EVM是一个仿真计算机,它运行一种称为EVM字节码的特殊形式的机器代码,就像计算机的CPU一样,它运行机器代码,如x86_64。我们将在[evm]中更详细地研究EVM的操作和语言。在本节中,我们将了解如何编写智能合约以在EVM上运行。
虽然可以直接在字节码中编写智能合约。EVM字节码难以处理,程序员很难阅读和理解。相反,大多数以太坊开发人员使用高级语言编写程序,并使用编译器将其转换为字节码。
虽然任何高级语言都可以用来编写智能合约,但这是一项非常繁琐的工作。智能合约在高度受限和简约的执行环境(EVM)中运行,几乎所有常用的用户界面、操作系统接口和硬件接口都缺失。从头开始构建简约智能合约语言比限制通用语言并使其适合编写智能合约更容易。结果,出现了许多用于编写智能合约的特殊用途语言。以太坊有几种这样的语言,以及生成EVM可执行字节码所需的编译器。
通常,编程语言可以分为两种广泛的编程范例:声明式和命令式,也分别称为“函数式”和“过程式”。在声明式编程中,我们编写表达程序逻辑的函数,而不是它的流程。声明式编程用于创建没有副作用的程序,意味着函数外部的状态没有变化。声明式编程语言包括例如Haskell,SQL和HTML。相比之下,命令式编程是程序员编写一组程序逻辑和流程的程序。命令式编程语言包括例如BASIC,C,C ++和Java。有些语言是“混合”的,这意味着它们鼓励声明式编程,但也可以用来表达命令式编程范例。这种混合包括Lisp,Erlang,Prolog,JavaScript和Python。通常,任何命令式语言都可以用于在声明性范例中编写,但它通常会导致代码不雅。相比之下,纯粹的声明式语言不能用于编写命令式范例。在纯粹的声明式语言中,没有“变量”。
虽然命令式编程更容易编写和读取,并且更常用于程序员,但编写完全按预期执行的程序可能非常困难。程序的任何部分都可以更改状态,这使得很难对程序的执行进行推理,并为意外的副作用和错误带来许多机会。通过比较进行声明式编程更难以编写,但避免了副作用,从而更容易理解程序的行为方式。
智能合约给程序员带来了很大的负担:bug需要花钱。因此,在不产生意外影响的情况下编写智能合约至关重要。为此,您必须能够清楚地了解程序的预期行为。因此,声明式语言在智能合约中的作用要大于通用软件中的作用。然而,正如您将在下面看到的,智能合约(Solidity)最多产的语言势在必行。
智能合约的高级编程语言包括(按年代排序):
- LLL:一种函数式(声明式)编程语言,具有类Lisp语法。这是Ethereum采用的第一个高级语言,但在今天很少使用。
- Serpent:一种过程式(命令式)编程语言,语法类似于Python。也可以用来编写函数式(声明式)代码,尽管它并不是完全没有副作用。使用较少。首先由Vitalik Buterin创建。
- Solidity:过程式(命令式)编程语言,其语法类似于JavaScript、c++或Java。Ethereum智能合约最流行、最常用的语言。作者是Gavin Wood(这本书的合著者)。
- Vyper:一种最近开发的语言,类似于Serpent,具有类似于python的语法。想要比Serpent更接近一种纯粹的类python语言,但不是为了取代Serpent。首先由Vitalik Buterin创建。
- Bamboo:一种新开发的语言,受Erlang的影响,具有显式的状态转换和没有迭代流(循环)。旨在减少副作用,增加可审核性。非常新颖,很少使用。
正如您所看到的,有许多语言可供选择。然而,到目前为止,Solidity是最受欢迎的,因为它是以太坊甚至是其他像evm一样的区块链上真正意义的高级语言。我们将花费大部分时间使用solid,但也将探索其他高级语言中的一些示例,以了解它们的不同哲学。
构建一个可靠的智能合约
引自维基百科:
Solidity是一种“面向合约的”编程语言,用于编写智能合约。它用于在各种区块链平台上实现智能合约。它是由Gavin Wood, Christian Reitwiessner, Alex Beregszaszi, Liana Husikyan, Yoichi Hirai和几个以前的Ethereum的核心贡献者在区块链平台上(如Ethereum)开发的。
--维基百科的Solidity条目
Solidity是由一个开发团队开发并维护的,GitHub上的Solidity项目:
https://github.com/ethereum/solidity
Solidity项目的主要“产品”是Solidity Compiler (solc),它将用Solidity语言编写的程序转换为EVM字节码,并生成其他的artefacts,如应用程序二进制接口(ABI)。Solidity编译器的每个版本对应并编译Solidity语言的特定版本。
首先,我们将下载Solidity编译器的二进制可执行文件。然后我们将编写以及编译一个简单的合约。
选择一个Solidity版本
Solidity遵循一个称为语义版本控制的版本控制模型(https://semver.org/),它指定版本号结构为三个由点分隔的数字:MAJOR.MINOR.PATCH。主要的和向后的不兼容的变更会增加“major”数字,“minor”数字会随着在主要版本之间添加向后兼容的特性而增加,“patch”数字会随着错误修复和与安全相关的变更而增加。
目前,Solidity在0.4.21版本,其中0.4是主要版本,21是次要版本,之后指定的任何内容都是补丁发布。即将发布0.5主要版本的Solidity。
正如我们在[intro]中看到的,您的Solidity程序可以包含一个pragma指令,该指令指定与之兼容的最小和最大的Solidity版本,并且可以用来编译您的合约。
由于Solidity正在快速发展,所以最好总是使用最新的版本。
下载/安装
有许多方法可以用来下载和安装Solidity,或者作为二进制版本,或者从源代码编译。你可在Solidity文件中找到详细的说明:
https://solidity.readthedocs.io/en/latest/installing-solidity.html
在[使用apt包管理器在Ubuntu/Debian上安装solc]时,我们将使用apt包管理器在Ubuntu/Debian操作系统上安装最新的Solidity二进制版本:
$ sudo add-apt-repository ppa:ethereum/ethereum
$ sudo apt update
$ sudo apt install solc
一旦安装了solc,请运行以下命令检查版本:
$ solc --version
solc, the solidity compiler commandline interface
Version: 0.4.21+commit.dfe3193c.Linux.g++
根据您的操作系统和需求,有许多其他方法来安装Solidity,包括直接从源代码编译。更多信息见:
https://github.com/ethereum/solidity
开发环境
要在Solidity中开发,您可以在命令行上使用任何文本编辑器和solc。但是,您可能会发现一些为开发而设计的文本编辑器,比如Atom,提供了一些额外的特性,比如语法高亮显示和宏,这些特性使solid开发更加容易。
还有基于web的开发环境,比如Remix IDE (https://remix.ethereum.org/)和EthFiddle (https://ethfiddle.com/)。
使用能让你高效工作的工具。最后,Solidity程序只是纯文本文件。虽然花哨的编辑器和开发环境可以使事情变得更简单,但是您只需要一个简单的文本编辑器,例如vim (Linux/Unix)、TextEdit (MacOS)甚至NotePad (Windows)。只需使用.sol扩展名保存程序源代码,它将被Solidity编译器识别为一个Solidity程序。
编写一个简单的Solidity程序
在[介绍]中,我们编写了第一个Solidity程序,叫做水龙头(Faucet)。当我们第一次构建水龙头时,我们使用Remix IDE编译和部署合约。在本节中,我们将重新讨论、改进和完善Faucet。
我们的第一次尝试是这样的:
Faucet.sol: 实现水龙头的Solidity合约
link:code/Solidity/Faucet.sol[]
我们将以第一个例子为基础,从[make_it_better]开始。
使用Solidity编译器(solc)进行编译
现在,我们将使用命令行上的Solidity编译器直接编译我们的合约。Solidity编译器solc提供了各种选项,您可以通过传递-help参数看到这些选项。
我们使用solc的--bin和--optimize参数来生成示例合约的优化二进制文件:
使用solc编译Faucet.sol
$ solc --optimize --bin Faucet.sol
======= Faucet.sol:Faucet =======
Binary:
6060604052341561000f57600080fd5b60cf8061001d6000396000f300606060405260043610603e5763ffffffff7c01000000000000000000000000000000000000000000000000000000006000350416632e1a7d4d81146040575b005b3415604a57600080fd5b603e60043567016345785d8a0000811115606357600080fd5b73ffffffffffffffffffffffffffffffffffffffff331681156108fc0282604051600060405180830381858888f19350505050151560a057600080fd5b505600a165627a7a723058203556d79355f2da19e773a9551e95f1ca7457f2b5fbbf4eacf7748ab59d2532130029
solc产生的结果是一个十六进制串行化的二进制文件,可以提交到Ethereum区块链。
Ethereum应用程序二进制接口(ABI)
在计算机软件中,应用程序二进制接口(ABI)是两个程序模块之间的接口;通常,一个在机器代码的级别,另一个在一个由用户运行的程序级别。ABI定义了如何在机器代码中访问数据结构和函数;不要把它与API混淆,API将这种访问定义为高级的、通常是人类可读的格式作为源代码。因此,ABI是编码和解码数据进出机器代码的主要方式。
在Ethereum中,ABI用于对EVM的合约调用进行编码,并从交易中读取数据。ABI的目的是定义可以调用合约中的哪些函数,并描述函数如何接受参数和返回数据。
合约ABI的JSON格式由函数描述(请参见[solidity_function])和事件(请参见[solidity_events])描述的数组给出。函数描述是一个JSON对象,包含type
、name
、inputs
、outputs
、constant
和payable的字段。事件描述对象具有type
、name
、inputs
和anonymous
字段。
我们使用solc命令行solidity编译器为我们的Faucet.sol示例合约生成ABI:
solc --abi Faucet.sol
======= Faucet.sol:Faucet =======
Contract JSON ABI
[{"constant":false,"inputs":[{"name":"withdraw_amount","type":"uint256"}],"name":"withdraw","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"payable":true,"stateMutability":"payable","type":"fallback"}]
如您所见,编译器生成一个JSON对象,该对象描述了Faucet.sol定义的两个函数。这个JSON对象可以被任何应用程序使用,一旦它被部署,它就会访问这个Faucet合约。使用ABI(一个应用程序,如钱包或DApp浏览器),可以构造调用水龙头中的函数的交易,使用正确的参数和参数类型。例如,一个钱包会知道要调用函数withdraw,它必须提供一个名为abstr_amount的uint256参数。钱包可以提示用户提供该值,然后创建一个对其进行编码并执行withdraw函数的交易。
应用程序与合约交互所需要的只是一个ABI和部署合约的地址。
选择Solidity编译器和语言版本
正如我们在编辑水龙头时看到的。使用solc我们成功地编译了Solidity 0.4.21版本的水龙头合约。但是如果我们使用的是另一个版本的Solidity编译器呢?语言仍在不断变化,事物可能以意想不到的方式发生变化。我们的合约相当简单,但是如果我们的程序使用的特性仅仅是在Solidity版本0.4.19中添加的,并且我们试图用0.4.18编译它,会怎么样呢?
为了解决这些问题,Solidity提供了一个编译器指令,称为版本pragma,它指示编译器程序需要一个特定的编译器(和语言)版本。让我们看一个例子:
pragma solidity ^0.4.19;
如果编译器版本与版本编译指示不兼容,那么Solidity编译器将读取版本编译指示并产生错误。在这种情况下,我们的版本编译指示说这个程序可以被一个0.4.19版本以上的Solidity编译器编译。符号^states,然而,我们允许编译0.4.19上面任何轻微的修改,例如,0.4.20,但不是0.5.0(这是一个主要的版本,不是一个小修改)。Pragma指令没有编译成EVM字节码。编译器只使用它们检查兼容性。
让我们在我们的水龙头合约中加入一个实用指令。我们将命名新的文件Faucet2.sol,在我们进行这些例子时,注意我们的变化:
link:code/Solidity/Faucet2.sol[]
添加版本编译指示是一种最佳实践,因为它避免了编译器和语言版本不匹配的问题。我们将探索其他最佳实践,并在本章中继续改进水龙头合约。
使用Solidity编程
在这一节中,我们将讨论Solidity语言的一些功能。正如我们在[介绍]中提到的,我们的第一个合约示例非常简单,并且在许多方面存在缺陷。我们将逐步改进这个示例,同时学习如何使用solid。不过,这不是一个全面的Solidity教程,因为Solidity是相当复杂和快速发展的。我们将介绍基本知识,并为您提供足够的基础,以便您能够自己探索其他知识。完整的Solidity文档载于:
https://solidity.readthedocs.io/en/latest/
数据类型
首先,让我们看看一些Solidity基本数据类型:
- boolean (bool):布尔值,真或假,带有逻辑运算符! (not), && (and), || (or), == (equal), != (not equal)。
- integer (int/uint):有符号(int)和无符号(uint)整数,从u/int8到u/int256的增量为8位。没有大小后缀,它们被设置为256位。
- fixed point (fixed/ufixed):定点数,定义为u/fixedMxN,其中M是位的大小(增量为8),N是小数点后的小数位。
- address:一个20字节Ethereum地址。地址对象具有成员余额(返回帐户余额)和转账(将ether转移到帐户)。
- byte array (fixed):固定大小的字节数组,定义为bytes1到bytes32
- byte array (dynamic):动态大小的字节数组,定义为字节或字符串
- enum:用于枚举离散值的用户定义类型。
- struct:用户定义用于分组变量的数据容器。
- mapping:key =>value对的哈希查找表。
除了上面的数据类型,Solidity还提供了各种各样的值类型,可以用来计算不同的单位:
- time units:单位秒、分钟、小时和天可以用作后缀,转换为基数单位秒的倍数。
- ether units:单位wei、finney、szabo和ether可以作为后缀,转换成单位wei的倍数。
到目前为止,在我们的Faucet合约示例中,我们使用了uint(它是uint256的别名),用于提取变量。我们还间接地使用了一个地址变量msg.sender。在本章中,我们将在示例中更多地使用这些数据类型。
让我们使用一个单位乘数,以提高我们的示例合约水龙头的可读性。在提取函数中,我们对最大提取量进行了限制,用ether的基元wei表示:
require(withdraw_amount <= 100000000000000000);
这不是很容易阅读,所以我们可以通过使用单位乘数ether来改进代码,用ether代替wei来表达这个值:
require(withdraw_amount <= 0.1 ether);
预定义全局变量和函数
当在EVM中执行合约时,它可以访问一组有限的全局对象。其中包括block、msg和tx对象。此外,Solidity将许多EVM操作码作为预定义的Solidity函数公开。在本节中,我们将检查在一个智能合约中您可以从一个Solidity合约中访问的变量和函数。
调用交易/消息上下文
msg对象是发起合约执行的交易(EOA发起)或消息(合约发起)。它包含许多有用的属性:
- msg.sender:我们已经用过这个了。它表示发起消息的地址。如果我们的合约被一个EOA交易调用,那么这就是签署该交易的地址。
- msg.value:发送消息用的以太值。
- msg.gas:在我们的合约中所剩的gas量。它已经被弃用,并将在solid v0.4.21中用gasleft()函数替换。
- msg.data:调用我们合约的消息的数据有效负载。
- msg.sig:数据负载的前四个字节,即函数选择器。
Note | 每当合约调用另一个合约时,msg的所有属性的值都会改变,以反映新的调用者的信息。唯一的例外是delegatecall函数,它在原始的msg上下文中运行另一个合约/库的代码。 |
---|
交易上下文:
- tx.gasprice:调用交易中的gas价格。
- tx.origin:来自发起(EOA)交易的完整调用堆栈。
区块上下文:
- block:区块对象包含有关当前区块的信息。
- block.blockhash(blockNumber):指定区块号的区块哈希,直到最后256区块为止。Solidity v.0.4.22已弃用,替换为blockhash()函数。
- block.coinbase:当前区块的矿工的地址。
- block.difficulty:当前区块的困难(工作证明)。
- block.gaslimit:当前区块的总gas限制。
- block.number:当前区块号(高度)。
- block.timestamp:从Unix纪元(秒)开始,由矿工放在当前区块中的时间戳。
地址对象
任何作为输入传递的地址或从合约对象转换的地址都具有许多属性和方法:
- address.balance:地址的余额(单位wei)。例如,当前合约余额是address(this).balance。
- address.transfer(amount):将金额(wei)转移到此地址,对任何错误抛出异常。我们在水龙头例子中使用了msg.sender.transfer()这个函数作为msg.sender地址的一个方法,。
- address.send(amount):与上面的transfer类似,它只会在错误时返回false而不会抛出异常。
- address.call():不常用的调用函数,可以构造任意的带有value、数据有效负载的消息。错误返回false。
- address.delegatecall():不常用的调用函数,保持调用合约的msg上下文,错误时返回false。
内置函数
- addmod, mulmod:取模的加法和乘法。例如,addmod(x,y,k)计算为 (x+y)%k。
- keccak256, sha256, sha3, ripemd160:使用各种标准哈希算法计算哈希的函数。
- ecrecover:从签名中恢复用于签名消息的地址。
合约的定义
Solidity的主要数据类型是contract对象,它在我们的水龙头示例的顶部定义。与面向对象语言中的任何对象相似,合约是包含数据和方法的容器。
Solidity还提供了另外两个与合约相似的对象:
- interface:接口定义的结构与合约完全相同,除了没有定义任何函数之外,它们只被声明。这种类型的函数声明通常称为stub,因为它告诉您参数,并返回所有类型的函数,而不使用任何实现。它用于指定合约接口,如果继承,则必须在子函数中指定每个函数。
- library:库合约是一种仅被部署一次并被其他合约使用的合约,使用delegatecall 方法(参见Address对象)。
函数
在合约中,我们定义可由EOA交易或其他合约调用的函数。在我们的水龙头示例中,我们有两个函数:withdraw函数和(未命名的)fallback函数。
函数的定义有以下语法:
function FunctionName([parameters]) {public|private|internal|external} [pure|constant|view|payable] [modifiers] [returns (<return types>)]
让我们来看看这些组成部分:
- FunctionName:定义函数的名称,该函数用于从交易(EOA)、其他合约或同一合约中调用函数。每个合约中的一个函数可以在没有名称的情况下定义,在这种情况下,它是回退函数,当没有其他函数被命名时,调用回退函数。回退函数不能有任何参数或返回任何内容。
- parameters:在名称之后,我们指定必须传递给函数的参数及其名称和类型。在我们的水龙头示例中,我们将uint withdraw_amount定义为withdraw函数的唯一参数。
下一组关键字(public, private, internal, external)指定函数的可见性:
- public:public是默认值,此类函数可以由其他合约、EOA交易或合约内部调用。在我们的水龙头示例中,这两个函数都被定义为public。
- external:外部函数类似于public,除了它们不能从合约中调用,除了它们以this作为前缀。
- private:内部函数仅在合约中“可见”,不能被其他合约或EOA交易调用。它们可以通过派生合约(继承它们的合约)调用。
- internal:私有函数类似于内部函数,但不能通过派生合约(继承它们的合约)调用它们。
记住,内部和私有的术语都有一定的误导性。在公共区块链中,合约中的任何函数或数据总是可见的,这意味着任何人都可以看到代码或数据。上面的关键字只影响函数的调用方式和调用时间。
下一组关键字(pure、constant、view、payable)会影响函数的行为:
- constant/view:标记为view的函数,承诺不修改任何状态。术语constant是将被弃用的view的别名。此时,编译器不会强制执行view修饰符,只会产生一个警告,但这将成为solid的v0.5中的一个强制关键字。
- pure:pure函数是既不读也不写变量的函数。它只能对参数进行操作并返回数据,而不引用任何存储的数据。pure函数旨在鼓励声明式编程,而不产生副作用或状态。
- payable:可支付的函数是可以接受输入的支付。没有应付款项的功能将拒绝收到的款项,除非它们源自一个共同基数(采矿收入)或作为自毁(合约终止)的目的地。在这些情况下,由于EVM中的设计决策,合约不能阻止支付。
正如您在我们的水龙头示例中所看到的,我们有一个可支付函数(fallback函数),它是唯一可以接收到支付的函数。
合约构造函数和selfdestruct
这是一个只使用一次的特殊函数。当创建一个合约时,它还运行构造函数(如果存在的话)来初始化合约的状态。构造函数在与创建合约相同的交易中运行。构造函数是可选的。实际上,我们的水龙头示例没有构造函数。
构造函数可以通过两种方式指定。Solidity v.0.4.21 以下,构造函数是一个函数,其名称与合约的名称相匹配:
contract MEContract {
function MEContract() {
// This is the constructor
}
}
这种格式的困难在于,如果更改了合约名称,且构造函数的名称没有更改,则不再是构造函数。这可能导致一些非常讨厌的、意想不到的和难以注意到的bug。例如,假设构造函数正在为控制目的设置合约的“所有者”。它不仅不会在合约成立时设置所有者,还可以像正常功能一样“callable”,允许任何第三方劫持合约,在合约成立后成为“所有者”。
为了解决基于相同名称作为合约的构造函数的潜在问题,Solidity v0.4.22引入了一个构造函数关键字,它的操作方式类似于构造函数,但没有名称。重命名合约并不影响构造函数。而且,更容易识别哪个函数是构造函数。它看起来像这样:
pragma ^0.4.22
contract MEContract {
constructor () {
// This is the constructor
}
}
因此,总而言之,合约的生命周期始于EOA或其他合约的创建交易。如果有构造函数,则从相同的创建交易中调用它,并可以在创建合约时初始化合约的状态。
合约生命周期的另一端是合约销毁。合约被一种叫做SELFDESTRUCT的特殊EVM操作码销毁。它曾经是名字自杀,但由于这个词的负面联想,这个名字被弃用了。在Solidity中,这个opcode被公开为一个名为selfdestruct的高级内建函数,它接受一个参数:在合约帐户中获得任何余额的地址。它看起来像这样:
selfdestruct(address recipient);
在我们的水龙头示例中添加构造函数和selfdestruct
我们在[intro]中介绍的水龙头示例合约没有任何构造函数或自毁函数。这是一个永远的合约,不能从区块链中删除。我们通过添加构造函数和selfdestruction函数来改变这一点。我们可能希望销毁只能由最初创建合约的EOA调用。按照惯例,这通常存储在地址变量中,称为owner。我们的构造函数设置所有者变量,而selfdestruction函数将首先检查所有者是否调用了它。
// Version of Solidity compiler this program was written for
pragma solidity ^0.4.22;
// Our first contract is a faucet!
contract Faucet {
address owner;
// Initialize Faucet contract: set owner
constructor() {
owner = msg.sender;
}
[...]
我们修改了pragma指令,将v0.4.22指定为本例的最小版本,因为我们使用的是只有在Solidity的v0.4.22中才存在的新构造函数关键字。我们的合约现在有一个名为owner的地址类型变量。“owner”这个名字在任何方面都不特殊。我们可以把这个地址变量叫做“potato”,仍然用同样的方法。名称所有者只是明确了意图和目的。
然后,我们的构造函数,作为合约创建交易的一部分运行,即从msg.sender分配地址到所有者变量。我们使用msg.sender在提取函数中识别提取请求的来源。然而,在构造函数中,msg.sender是签署合约创建交易的EOA或合约地址。我们知道这是一种情况,因为这是一个构造函数:它只运行一次,而且只作为创建合约交易的结果。
好的,现在我们可以添加一个函数来毁坏合约。我们需要确保只有所有者可以运行这个函数,因此我们将使用require语句来控制访问。下面是它的样子:
// Contract destructor
function destroy() public {
require(msg.sender == owner);
selfdestruct(owner);
}
如果其他任何人从所有者以外的地址调用此销毁函数,它将失败。但是如果构造函数调用的相同地址被构造函数调用,则该合约将自毁并将剩余的余额发送给所有者地址。
函数修饰符
Solidity提供了一种特殊类型的函数,称为函数修饰符。通过在函数声明中添加修饰符名,可以对函数应用修饰符。修改函数通常用于创建适用于合约中的许多函数的条件。我们已经有了一个访问控制语句,在我们的销毁函数中。让我们创建一个函数修饰符来表达这个条件:
onlyOwner函数修饰符
modifier onlyOwner {
require(msg.sender == owner);
_;
}
在onlyOwner函数修饰符中,我们看到函数修饰符的声明,名为onlyOwner。该函数修饰符在它修改的任何函数上设置一个条件,要求作为合约所有者存储的地址与交易的msg.sender的地址相同。这是访问控制的基本设计模式,允许只有合约的所有者执行任何具有唯一所有者修饰符的函数。
您可能已经注意到,我们的函数修饰符中有一个特殊的语法“占位符”,下划线后面跟着分号(_;)。这个占位符被正在修改的函数的代码所取代。基本上,修饰符被“包装”在修改后的函数中,将其代码放在由下划线字符标识的位置。
要应用修饰符,请将其名称添加到函数声明中。可以将多个修饰符应用于函数(作为逗号分隔的列表),并在声明它们的序列中应用。
让我们重写我们的销毁函数来使用唯一的所有者修饰符:
function destroy() public onlyOwner {
selfdestruct(owner);
}
函数修饰符的名称(onlyOwner)位于关键字public之后,并告诉我们销毁函数是由onlyOwner修饰符修改的。从本质上来说,你可以把它理解为“只有所有者才能销毁这份合约”。在实践中,产生的代码相当于“包装”来自仅有的所有者的代码。
函数修饰符是一种非常有用的工具,因为它们允许我们为函数编写前置条件并一致地应用它们,使代码更易于阅读,从而更容易对安全性问题进行审计。它们通常用于访问控制,如示例[onlyOwner函数修饰符],但是它们非常通用,可以用于各种其他目的。
在修饰符中,您可以访问修改函数可见的所有符号(变量和参数)。在这种情况下,我们可以访问owner变量,它在合约中声明。但是,反过来并不是正确的:您不能访问修饰符在修改函数中的任何变量。
合约继承
Solidity的合约对象支持继承,这是一种使用附加功能扩展基本合约的机制。若要使用继承,请指定具有关键字的父合约为:
contract Child is Parent {
}
通过这个构造,子合约继承了父合约的所有方法、功能和变量。Solidity还支持多重继承,可以通过关键字后面的逗号分隔的合约名指定:
contract Child is Parent1, Parent2 {
}
合约继承允许我们以这样的方式编写合约,以实现模块化、可扩展性和重用。我们从简单的合约开始,并实现最通用的功能,然后通过在更专门化的合约中继承这些功能来扩展它们。
在我们的水龙头合约中,我们介绍了构造函数和析构函数,以及分配给所有者的访问控制。这些功能是非常通用的:许多合约都有它们。我们可以将它们定义为泛型合约,然后使用继承将它们扩展到水龙头合约。
我们首先定义一个所有者的基本合约,它有所有者变量,并将其设置在合约的构造函数中:
contract owned {
address owner;
// Contract constructor: set owner
constructor() {
owner = msg.sender;
}
// Access control modifier
modifier onlyOwner {
require(msg.sender == owner);
_;
}
}
接下来,我们定义一个基本合约mortal,继承其owned:
contract mortal is owned {
// Contract destructor
function destroy() public onlyOwner {
selfdestruct(owner);
}
}
如您所见,mortal合约可以使用唯一的所有者函数修饰符,定义在owner中。它还间接使用所有者地址变量和所有者定义的构造函数。继承使每个合约都更简单,并且关注类的特定功能,允许我们以模块化的方式管理细节。
现在我们可以进一步扩展owned合同,继承其在Faucet上的功能:
contract Faucet is mortal {
// Give out ether to anyone who asks
function withdraw(uint withdraw_amount) public {
// Limit withdrawal amount
require(withdraw_amount <= 100000000000000000);
// Send the amount to the address that requested it
msg.sender.transfer(withdraw_amount);
}
// Accept any incoming amount
function () public payable {}
}
通过继承mortal,而mortal又继承了自己的owned,水龙头合约现在拥有构造函数和销毁函数,以及一个定义的所有者。功能与水龙头内的功能相同,但现在我们可以在其他合约中重用这些功能,而不必再编写它们。代码重用和模块化使我们的代码更清晰、更容易阅读和更容易审计。
错误处理(assert, require, revert)
合约调用可以终止并返回错误。在Solidity中,错误处理由4个函数来处理:assert、require、restore和throw(现在已弃用)。
当一个合约以错误结束时,如果调用了多个合约,则所有的状态变化(对变量、余额等的更改)都将被恢复,并一直向上循环到合约调用的链中。这确保交易是原子的,这意味着它们要么成功完成,要么对状态没有影响,然后完全恢复。
assert和require函数以相同的方式操作,评估条件,如果条件为假,则停止执行。根据约定,assert在预期结果为真时使用,这意味着我们使用assert来测试内部条件。相比之下,在测试输入(如函数参数或交易字段)时,我们使用require,以设置我们对这些条件的期望。
我们已经在我们的函数修饰符中使用了require,以测试消息发送方是该合约的所有者:
require(msg.sender == owner);
require函数作为一个gate条件,阻止函数其余部分的执行,如果不满足就会产生错误。
从Solidity v.0.4.22开始,require还可以包含一个有用的文本消息,可以用来显示错误的原因。错误消息被记录在交易日志中。因此,我们可以通过在require函数中添加错误消息来改进代码:
require(msg.sender == owner, "Only the contract owner can call this function");
revert和throw函数,停止执行合约并恢复任何状态更改。throw函数已经过时,将在以后的Solidity版本中删除——您应该使用“revert”。还原函数还可以将错误消息作为唯一的参数,该参数记录在交易日志中。
合约中的某些条件会产生错误,不管我们是否明确地检查它们。例如,在我们的水龙头合约中,我们不检查是否有足够的ether来满足提取要求。这是因为,如果没有足够的余额进行转账,则传输函数将失败并出现错误,并恢复交易:
如果余额不足,transfer函数将失败
msg.sender.transfer(withdraw_amount);
但是,最好是显式地检查并在失败时提供明确的错误消息。我们可以通过在转账前添加一个要求声明来做到这一点:
require(this.balance >= withdraw_amount,
"Insufficient balance in faucet for withdrawal request");
msg.sender.transfer(withdraw_amount);
像这样额外的错误检查代码将会稍微增加gas消耗,但是它提供了比忽略更好的错误报告。在gas消耗和详细的错误检查之间找到正确的平衡是你需要根据你的合约的预期使用来决定的。对于一个用于测试网络的水龙头,我们可能会在额外的报告上犯错误,即使它需要更多的gas。也许对于主网络合约,我们会选择节约使用gas。
Events
事件是促进交易日志生成的可靠构造。当一个交易完成(成功与否)时,它将产生一个交易收据,我们将在[evm]中看到。交易收据包含日志条目,这些条目提供关于在执行交易期间发生的操作的信息。事件是用于构造这些日志的可靠的高级对象。
事件在轻量级客户端和DApps中尤其有用,它们可以“监视”特定事件并将它们报告给用户界面,或者更改应用程序的状态以反映底层合约中的事件。
事件对象接受序列化并记录在交易日志(在区块链中)中的参数。可以在参数之前提供关键字索引,使索引表(哈希表)的值部分能够被应用程序搜索或过滤。
到目前为止,我们还没有在我们的水龙头示例中添加任何事件,所以让我们这样做。我们将添加两个事件,一个用于记录取款,一个用于记录存款。我们将这些事件分别称为Withdrawal和Deposit。首先,我们定义水龙头合约中的事件:
contract Faucet is mortal {
event Withdrawal(address indexed to, uint amount);
event Deposit(address indexed from, uint amount);
[...]
}
我们选择将地址编入索引,以便在任何用于访问水龙头的用户界面中进行搜索和过滤。
接下来,我们使用emit关键字将事件数据合并到交易日志中:
// Give out ether to anyone who asks
function withdraw(uint withdraw_amount) public {
[...]
msg.sender.transfer(withdraw_amount);
emit Withdrawal(msg.sender, withdraw_amount);
}
// Accept any incoming amount
function () public payable {
emit Deposit(msg.sender, msg.value);
}
由此产生的Faucet.sol合约是这样的:
Faucet8.sol:修改后的水龙头合约,包括事件
link:code/Solidity/Faucet8.sol[]
捕获事件
好的,我们已经建立了合约去emit事件。我们如何看到交易的结果并“捕获”事件?web3.js库提供一个数据结构,作为包含交易日志的交易的结果。在其中,我们可以看到交易生成的事件。
让我们使用truffle对修改后的水龙头合约进行测试。按照[truffle]中的说明设置一个项目目录并编译Faucet代码。源代码可以在本书的GitHub资源库中找到:
code/truffle/FaucetEvents
$ truffle develop
truffle(develop)> compile
truffle(develop)> migrate
Using network 'develop'.
Running migration: 1_initial_migration.js
Deploying Migrations...
... 0xb77ceae7c3f5afb7fbe3a6c5974d352aa844f53f955ee7d707ef6f3f8e6b4e61
Migrations: 0x8cdaf0cd259887258bc13a92c0a6da92698644c0
Saving successful migration to network...
... 0xd7bc86d31bee32fa3988f1c1eabce403a1b5d570340a3a9cdba53a472ee8c956
Saving artifacts...
Running migration: 2_deploy_contracts.js
Deploying Faucet...
... 0xfa850d754314c3fb83f43ca1fa6ee20bc9652d891c00a2f63fd43ab5bfb0d781
Faucet: 0x345ca3e014aaf5dca488057592ee47305d9b3e10
Saving successful migration to network...
... 0xf36163615f41ef7ed8f4a8f192149a0bf633fe1a2398ce001bf44c43dc7bdda0
Saving artifacts...
truffle(develop)> Faucet.deployed().then(i => {FaucetDeployed = i})
truffle(develop)> FaucetDeployed.send(web3.toWei(1, "ether")).then(res => { console.log(res.logs[0].event, res.logs[0].args) })
Deposit { from: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
amount: BigNumber { s: 1, e: 18, c: [ 10000 ] } }
truffle(develop)> FaucetDeployed.withdraw(web3.toWei(0.1, "ether")).then(res => { console.log(res.logs[0].event, res.logs[0].args) })
Withdrawal { to: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
amount: BigNumber { s: 1, e: 17, c: [ 1000 ] } }
在使用deploy()函数获得部署的合约之后,我们执行两个交易。第一个交易是存款(使用send),它在交易日志中发出一个存款事件:
Deposit { from: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
amount: BigNumber { s: 1, e: 18, c: [ 10000 ] } }
接下来,我们使用withdraw函数进行提取。这就产生了提取事件:
Withdrawal { to: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
amount: BigNumber { s: 1, e: 17, c: [ 1000 ] } }
为了获得这些事件,我们查看了作为交易结果(res)返回的日志数组。第一个日志条目(log[0])在log[0]中包含一个事件名。事件和logs[0].args中的事件参数。通过在控制台中显示这些,我们可以看到发出的事件名称和事件参数。
事件是一种非常有用的机制,不仅用于合约内通信,还用于开发期间的调试。
调用其他合约 (call, send, delegatecall, callcode)
从您的合约中调用其他合约是一个非常有用但潜在危险的操作。我们将研究实现这一目标的各种方法,并评估每种方法的风险。
创建一个新的实例
调用另一个合约最安全的方法是您自己创建另一个合约。这样,您就确定了它的接口和行为。要做到这一点,您可以简单地实例化它,使用关键字new,就像任何面向对象语言一样。在solid中,关键字new将在区块链上创建合约,并返回一个您可以用来引用它的对象。假设你想要从另一个叫做Token的合约中创建并调用一个水龙头合约:
contract Token is mortal {
Faucet _faucet;
constructor() {
_faucet = new Faucet();
}
}
这种合约构建机制确保您知道合约的确切类型及其接口。合约水龙头必须在Token范围内定义,如果定义在另一个文件中,您可以使用导入语句:
import "Faucet.sol"
contract Token is mortal {
Faucet _faucet;
constructor() {
_faucet = new Faucet();
}
}
新的关键字还可以接受可选参数,以指定在创建时的ether传输值,以及传递给新合约的构造函数的参数,如果有的话:
import "Faucet.sol"
contract Token is mortal {
Faucet _faucet;
constructor() {
_faucet = (new Faucet).value(0.5 ether)();
}
}
如果我们赋予创建的水龙头一些ether,我们也可以调用水龙头函数,它的操作就像一个方法调用。在本例中,我们从Token的销毁函数中调用了Faucet的销毁函数:
import "Faucet.sol"
contract Token is mortal {
Faucet _faucet;
constructor() {
_faucet = (new Faucet).value(0.5 ether)();
}
function destroy() ownerOnly {
_faucet.destroy();
}
}
寻址现有的实例
我们可以使用另一种方式来调用合约,即对合约的一个现有实例的地址进行强制转换。使用这个方法,我们将一个已知的接口应用到一个现有的实例。因此,至关重要的一点是,我们肯定地知道,我们正在处理的实例实际上与我们假设的类型相同。让我们看一个例子:
import "Faucet.sol"
contract Token is mortal {
Faucet _faucet;
constructor(address _f) {
_faucet = Faucet(_f);
_faucet.withdraw(0.1 ether)
}
}
在这里,我们将提供的地址作为构造函数的参数,并将其转换为水龙头对象。这比之前的机制要危险得多,因为我们实际上不知道那个地址是否真的是一个水龙头对象。当我们调用withdraw时,我们假设它接受相同的参数并执行与我们的水龙头声明相同的代码,但是我们不能确定。据我们所知,这个地址的withdraw函数可以执行与我们期望的完全不同的内容,即使它的名称是相同的。因此,使用作为输入传递的地址并将其转换为特定对象比自己创建合约要危险得多。
Raw call, delegatecall
Solidity为调用其他合约提供了一些更“低级”的功能。它们直接对应于同名的EVM操作码,并允许我们手工构造从合约到合约的调用。因此,它们代表了调用其他合约的最灵活和最危险的机制。
这里是同样的例子,使用call方法:
contract Token is mortal {
constructor(address _faucet) {
_faucet.call("withdraw", 0.1 ether);
}
}
正如您所看到的,这种类型的调用,是对函数的盲调用,非常类似于构造原始交易,仅从合约的上下文中进行。它可以使我们的合约暴露在许多安全风险中,最重要的是可重入性,我们将更详细地讨论[reentrancy]。如果出现问题,调用函数将返回false,因此我们可以计算返回值,进行错误处理:
contract Token is mortal {
constructor(address _faucet) {
if !(_faucet.call("withdraw", 0.1 ether)) {
revert("Withdrawal from faucet failed");
}
}
}
调用的另一种变体是delegatecall,它取代了非常危险的callcode。callcode方法将很快被弃用,因此不应该使用它。
正如Address对象中所提到的,delegatecall不同于调用,因为msg上下文不会改变。例如,一个调用改变了msg的值。发送方作为调用合约,委托方保留相同的msg。发送者就像在呼叫合约中一样。从本质上说,delegatecall在当前合约的上下文中运行另一个合约的代码。它通常用于从库调用代码。
应该非常小心地使用delegatecall。它可能会有一些意想不到的效果,特别是如果您调用的合约不是作为一个库设计的。
让我们使用一个示例合约,演示call和delegatecall用于调用库和合约的各种调用语义。我们使用一个事件来记录每个调用的起源,并查看调用上下文如何根据调用类型变化:
CallExamples.sol: 不同调用语义的一个例子。
link:code/truffle/CallExamples/contracts/CallExamples.sol[]
我们的主要合约是caller,它调用一个名calledLibrary的库和一个名calledContract的合同。被调用的库和合约都具有相同的函数calledFunction,它发出一个事件calledEvent。事件calledEvent记录了三段数据:mmsg.sender、tx.origin和this。每次调用函数时,都可能有不同的执行上下文(例如,在msg.sender中有不同的值),这取决于它是direclty还是通过delegatecall。
在caller中,我们首先直接调用合约和库,通过调用每个函数中的calledFunction。然后,我们显式地使用低级函数call和delegatecall来调用calledContract.calledFunction。通过这种方式,我们可以看到各种调用机制的行为。
让我们在truffle开发环境中运行它,并捕获事件,看看它是什么样子的:
truffle(develop)> migrate
Using network 'develop'.
[...]
Saving artifacts...
truffle(develop)> web3.eth.accounts[0]
'0x627306090abab3a6e1400e9345bc60c78a8bef57'
truffle(develop)> caller.address
'0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f'
truffle(develop)> calledContract.address
'0x345ca3e014aaf5dca488057592ee47305d9b3e10'
truffle(develop)> calledLibrary.address
'0xf25186b5081ff5ce73482ad761db0eb0d25abfbf'
truffle(develop)> caller.deployed().then( i => { callerDeployed = i })
truffle(develop)> callerDeployed.make_calls(calledContract.address).then(res => { res.logs.forEach( log => { console.log(log.args) })})
{ sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10' }
{ sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f' }
{ sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10' }
{ sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f' }
让我们看看这里发生了什么。我们调用了make_call函数并传递了calledContract的地址,然后捕获了每个不同调用发出的四个事件。看一下make_calls函数,让我们来遍历每一步。
第一个调用是:
_calledContract.calledFunction();
在这里,我们直接调用calledContract.calledFunction,使用高级ABI来调用函数。发出的事件是:
sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10'
如你所见,msg.sender是caller合约的地址。tx.origin是我们钱包web3.eth.accounts[0]的地址,它将交易发送给caller。事件是由calledContract发出的,我们可以从事件的最后一个参数中看到。
make_calls中的下一个调用是调用library:
calledLibrary.calledFunction();
它看起来和我们所谓的合约是一样的,但行为却非常不同。让我们看一下发出的第二个事件:
sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f'
这一次,the msg.sender不是caller的地址。相反,它是我们钱包的地址,和我们的交易发起是一样的。这是因为当您调用一个库时,调用始终用的是delegatecall,并在caller的上下文中运行。因此,当calledLibrary代码运行时,它继承了caller的执行上下文,就好像它的代码在调用者内部运行一样。这个变量(从发出的事件中显示)是caller的地址,即使它是从calledLibrary中访问的。
接下来的两个调用,使用低级的call和delegatecall,验证我们的预期,发出与上面看到的类似的事件。
Gas注意事项
Gas在[Gas]一节中有更详细的描述,它是智能合约编程中非常重要的考虑因素。gas是限制Ethereum允许交易使用的最大计算量的资源。如果在计算过程中超过gas limit,则会发生以下一系列事件:
- 一个“out of gas”异常被抛出。
- 在函数执行之前的合约状态被恢复。
- 所有的gas都作为交易费用交给矿工,不退还。
因为gas是由创建交易的用户支付的,所以不鼓励用户调用具有高开销的函数。因此,将合约功能的gas成本降到最低对程序员来说是最有利的。为此目的,在构建智能合约时,推荐使用某些实践,以最小化函数调用周围的gas成本。
避免动态大小的数组
在动态大小的数组中,函数对每个元素执行操作或搜索特定元素的任何循环都会带来使用过多gas的风险。在找到期望的结果之前,或者在对每个元素采取行动之前,合约可能会耗尽gas。
避免调用其他合约
调用其他合约,特别是在不知道其功能的gas成本的情况下,会引入耗尽gas的风险。避免使用没有经过良好测试和广泛使用的库。一个库从其他程序员那里得到的审查越少,使用它的风险就越大。
估计gas成本
如果你需要估计执行某一合约的某一方法所需要的气体,考虑它的调用参数,例如,你可以使用以下程序;
var contract = web3.eth.contract(abi).at(address);
var gasEstimate = contract.myAweSomeMethod.estimateGas(arg1, arg2, {from: account});
gasEstimate将告诉我们执行它所需要的气体单位的数量。
从你可以使用的网络中获取gas price;
var gasPrice = web3.eth.getGasPrice();
然后,估计gas的成本;
ar gasCostInEther = web3.fromWei((gasEstimate * gasPrice), 'ether');
让我们应用我们的gas cost函数来估计我们的水龙头例子的gas成本,使用书中的代码:
code/truffle/FaucetEvents
我们在开发模式中启动truffle,并执行一个JavaScript文件gas_estimate.js,其中包含:
gas_estimates.js: 使用estimateGas函数
var FaucetContract = artifacts.require("./Faucet.sol");
FaucetContract.web3.eth.getGasPrice(function(error, result) {
var gasPrice = Number(result);
console.log("Gas Price is " + gasPrice + " wei"); // "10000000000000"
// Get the contract instance
FaucetContract.deployed().then(function(FaucetContractInstance) {
// Use the keyword 'estimateGas' after the function name to get the gas estimation for this particular function (aprove)
FaucetContractInstance.send(web3.toWei(1, "ether"));
return FaucetContractInstance.withdraw.estimateGas(web3.toWei(0.1, "ether"));
}).then(function(result) {
var gas = Number(result);
console.log("gas estimation = " + gas + " units");
console.log("gas cost estimation = " + (gas * gasPrice) + " wei");
console.log("gas cost estimation = " + FaucetContract.web3.fromWei((gas * gasPrice), 'ether') + " ether");
});
});
以下是truffle开发控制台的结果:
$ truffle develop
truffle(develop)> exec gas_estimates.js
Using network 'develop'.
Gas Price is 20000000000 wei
gas estimation = 31397 units
gas cost estimation = 627940000000000 wei
gas cost estimation = 0.00062794 ether
建议您将估计gas成本函数作为开发工作流程的一部分,以避免在将合约部署到mainnet时出现任何意外。
安全注意事项
在编写智能合约时,安全性是最重要的考虑因素之一。与其他程序一样,一个智能合约将执行所写的内容,这并不总是程序员想要的。此外,所有的智能合约都是公开的,任何用户只需创建一个交易就可以与它们交互。任何漏洞都可以被利用,损失几乎总是不可能恢复。
在智能合约编程领域,错误代价高昂,很容易被利用。因此,遵循最佳实践并使用经过良好测试的设计模式是至关重要的。
防御性编程是一种特别适合编写智能合约的编程风格,具有以下特点:
-
极简主义/简洁:复杂性是安全的敌人。代码越简单,它所做的就越少,出现错误或无法预见的影响的可能性就越低。当开发人员第一次参与智能合约编程时,他们会尝试编写大量代码。相反,您应该检查您的智能合约代码,并尝试寻找方法,以更少的代码行、更少的复杂性和更少的“特性”来做更少的工作。如果有人告诉您,他们的项目已经生成了“数千行代码”,您应该质疑该项目的安全性。更简单更安全。
-
代码重用:尽量不要“重新发明轮子”。如果一个库或合约已经存在,可以满足您的大部分需求,那么请重新使用它。在你自己的代码中,遵循DRY的原则:不要重复你自己。如果您看到任何代码片段重复不止一次,请扪心自问它是否可以写成函数或库,并重新使用。被广泛使用和测试的代码可能比您编写的任何新代码更安全。要注意“非我发明”的态度,在这种态度下,您可能会尝试从头构建一个特性或组件来“改进”它。安全风险通常大于改进值。
-
代码质量:Smart-contract代码是不可原谅的。每一个错误都会导致金钱的损失。您不应该将智能合约编程视为通用编程。相反,您应该应用严格的工程和软件开发方法,类似于航空航天工程或类似的不宽容的工程学科。一旦你“启动”你的代码,你几乎没有什么可以去修复任何问题。
-
可读性和可审查性:您的代码应该易于理解和清理。读起来越容易,审计就越容易。智能合约是公开的,因为任何人都可以反向设计字节码。因此,您应该使用协作和开源方法在公共场合开发您的工作。您应该按照Ethereum社区中样式约定和命名约定编写文档良好且易于阅读的代码。
-
测试覆盖率:测试所有你能测试的东西。智能合约运行在公共执行环境中,任何人都可以用他们想要的任何输入来执行它们。您永远不应该假设输入(如函数参数)是格式良好的、有适当的边界的,并且具有良好的目的。测试所有参数,以确保它们在预期范围内并正确格式化。
常见的安全风险
智能合约程序员应该熟悉许多最常见的安全风险,以便能够检测和避免使他们暴露于这些风险的编程模式。
Re-entrancy
Re-entrancy是编程中的一种现象,其中一个函数或程序被中断,然后在之前的调用完成之前再次调用。在智能合约编程的上下文中,当合约A调用合约B中的一个函数时,可以重新进入,而合约B反过来调用合约A中的相同函数,从而导致递归执行。在关键调用结束后才更新合约状态的情况下,这可能是特别危险的。
要理解这一点,可以想象一个钱包合约里的withdrawal叫做银行合约。合约A调用合约B中的withdraw功能,试图提取金额X。该方案将涉及以下行为:
- 合约B检查A是否有提取X所需的余额
- B将X转到A的地址(运行一个payable fallback函数)
- B更新A的余额以反映提款情况
无论何时向合约发送付款,如本例中所示,接收方合约(a)都有机会执行一个payable函数,如默认fallback函数。然而,恶意攻击者可以利用这种执行。假设在A的payable fallback函数中,合约A再次调用银行B的withdraw函数。B的withdraw函数现在将重新进入,因为相同的初始交易现在正在引起循环调用。
"(1)A调用B(2)调用A的payable函数(1)再次调用B "
在B withdrawal函数的第二次重复中,B将再次检查A是否有可用的余额。由于第3步(更新A的余额)还没有执行,因此B认为A仍然有可用的资金可以提取,无论这个函数被重新调用多少次。这个循环可以重复,只要有gas可以持续运行。当A检测到gas正在耗尽时,它可以停止在payable函数中调用B。B最终执行步骤3,从A的余额中减去X。但此时,B可能已经执行了数百次转账,并且只扣除一次。A通过这次攻击有效地从B身上抽走了资金。
这个漏洞因为与DAO攻击相关而特别出名。一名用户利用了这样一个事实,即合约中的余额在接到转账调用并提取了价值数百万美元的ether后发生了变化。
为了防止re-entrancy,最好的做法是程序员使用Checks-Effects-Interactions
模式,其中函数调用的效果(例如减少余额)在调用之前发生。在我们的示例中,这意味着转换步骤3和步骤2:在转移之前更新用户的余额。
在Ethereum中,这是完全可以接受的,因为交易的所有影响都是原子的,这意味着在用户没有支付的情况下,不可能更新余额。要么同时发生,要么抛出异常,但都没有发生。这可以防止re-entrancy攻击,因为所有后续调用都将遇到正确的修改后的余额。通过切换这两个步骤,可以防止A的提款超过其余额。
Delegatecall
调用方法,如前所述,从调用合约的上下文中“调用”到一个函数。
设计模式
任何编程范式的软件开发人员通常都会遇到围绕行为、结构、交互和创建主题的反复设计挑战。通常,这些问题可以被推广并重新应用到类似性质的未来问题中。当给定一个正式的结构时,这些概括被称为设计模式。智能合约有他们自己的一组反复的设计问题,可以使用下面描述的一些模式来解决这些问题。
在智能合约的开发过程中,存在着无数的设计问题,因此不可能在这里讨论所有这些问题。因此,本节将重点讨论智能合约设计中最常见的三个问题分类:访问控制、状态流和资金支付。
在这一节中,我们将编写一个最终将所有这三种设计模式结合在一起的合约。该合约将运行一个投票系统,允许用户对“真相”进行投票。该合约将提出诸如“小熊队赢得了世界大赛”或“纽约正在下雨”之类的主张,然后用户将有机会投票真假。如果大多数参与者投票为真,合约将认为该命题为真,如果大多数参与者投票为假,合约也将认为该命题为假。为了激励真实性,每一次投票都必须向合约投送100ether,败选的少数人的捐款将被大多数人瓜分。大多数人的每一个参与者都将从少数人那里获得他们的部分奖金以及他们最初的投资。
这种“真相投票”系统实际上是Gnosis的基础,Gnosis是一个建立在Ethereum之上的预测工具。更多关于Gnosis的信息可以在这里找到:https://gnosis.pm/。
访问控制
访问控制限制哪些用户可以调用合约函数。例如,真实投票合约的所有人可以决定限制那些可以参与投票的人。为了实现这一目标,合约必须实施两个访问限制:
- 只有合约的所有者可以将新用户添加到“允许投票者”列表中
- 只有被允许的选民才可以投票
Solidity函数修饰符提供了一种实现这些限制的简明方法。
注意:下面的示例在修饰符主体中使用下划线分号。这是一个Solidity特性,用于告诉编译器何时运行修改后的函数体。开发人员可以将修改后的函数的主体复制到下划线的位置。
pragma solidity ^0.4.21;
contract TruthVote {
address public owner = msg.sender;
address[] true_votes;
address[] false_votes;
mapping (address => bool) voters;
mapping (address => bool) hasVoted;
uint VOTE_COST = 100;
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
modifier onlyVoter() {
require(voters[msg.sender] != false);
_;
}
modifier hasNotVoted() {
require(hasVoted[msg.sender] == false);
_;
}
function addVoter(address voter)
public
onlyOwner()
{
voters[voter] = true;
}
function vote(bool val)
public
payable
onlyVoter()
hasNotVoted()
{
if (msg.value >= VOTE_COST) {
if (val) {
true_votes.push(msg.sender);
} else {
false_votes.push(msg.sender);
}
hasVoted[msg.sender] = true;
}
}
}
修饰符及功能说明:
- onlyOwner:这个修饰符可以修饰一个函数,这样该函数就只能由发送者调用,其地址与所有者的地址相匹配。
- onlyVoter:这个修饰符可以修饰一个函数,这样该函数就只能由已注册投票者调用。
- addVoter(voter):此函数用于将投票者添加到投票者列表中。此函数使用onlyOwner修饰符,因此只有合约的所有者可以调用它。
- vote(val):投票人使用这个函数来对所提出的提案投真或假的票。它使用onlyVoter修饰符,所以只有注册选民可以调用它。
状态流
许多合约需要一些操作状态的概念。合约的状态将决定合约如何运行,以及在给定的时间点它将提供什么操作。让我们回到我们的真相投票系统,寻找一个更具体的例子。
我们的投票系统的运作可分为三种不同的状态。
- Register:服务已经创建,所有者现在可以添加投票者。
- Vote:所有的选民都投了票。
- Disperse:投票付款分为两部分,并发给大多数参与者。
下面的代码继续构建在访问控制代码之上,但是进一步将功能限制到特定的状态。在Solidity中,通常使用枚举值来表示状态。
pragma solidity ^0.4.21;
contract TruthVote {
enum States {
REGISTER,
VOTE,
DISPERSE
}
address public owner = msg.sender;
uint voteCost;
address[] trueVotes;
address[] falseVotes;
mapping (address => bool) voters;
mapping (address => bool) hasVoted;
uint VOTE_COST = 100;
States state;
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
modifier onlyVoter() {
require(voters[msg.sender] != false);
_;
}
modifier isCurrentState(States _stage) {
require(state == _stage);
_;
}
modifier hasNotVoted() {
require(hasVoted[msg.sender] == false);
_;
}
function startVote()
public
onlyOwner()
isCurrentState(States.REGISTER)
{
goToNextState();
}
function goToNextState() internal {
state = States(uint(state) + 1);
}
modifier pretransition() {
goToNextState();
_;
}
function addVoter(address voter)
public
onlyOwner()
isCurrentState(States.REGISTER)
{
voters[voter] = true;
}
function vote(bool val)
public
payable
isCurrentState(States.VOTE)
onlyVoter()
hasNotVoted()
{
if (msg.value >= VOTE_COST) {
if (val) {
trueVotes.push(msg.sender);
} else {
falseVotes.push(msg.sender);
}
hasVoted[msg.sender] = true;
}
}
function disperse(bool val)
public
onlyOwner()
isCurrentState(States.VOTE)
pretransition()
{
address[] memory winningGroup;
uint winningCompensation;
if (trueVotes.length > falseVotes.length) {
winningGroup = trueVotes;
winningCompensation = VOTE_COST + (VOTE_COST*falseVotes.length) / trueVotes.length;
} else if (trueVotes.length < falseVotes.length) {
winningGroup = falseVotes;
winningCompensation = VOTE_COST + (VOTE_COST*trueVotes.length) / falseVotes.length;
} else {
winningGroup = trueVotes;
winningCompensation = VOTE_COST;
for (uint i = 0; i < falseVotes.length; i++) {
falseVotes[i].transfer(winningCompensation);
}
}
for (uint j = 0; j < winningGroup.length; j++) {
winningGroup[j].transfer(winningCompensation);
}
}
}
修饰符及功能说明:
- isCurrentState:在继续执行修饰函数之前,该修饰符将要求合约处于指定的状态。
- pretransition:在执行修饰函数的其余部分之前,该修饰符将转换到下一个状态。
- goToNextState:函数,将合约转换为下一个状态
- disperse:相应计算出多数和分发的函数。只有所有者可以调用这个函数来正式结束投票。
- startVote:所有者可以用来开始投票的功能。
需要注意的是,允许所有者随意关闭投票程序会导致本合约被滥用。在一个更真实的执行中,投票期间应在公众了解的一段时间后结束。对于这个例子,这是可以的。
现在增加的内容确保只有当所有者决定开始投票时才允许投票,用户只能在投票发生前由所有者注册,资金只能在投票结束后才会分发。
提款
许多合约将为用户提供一些从合约中获取金钱的方法。在我们的工作示例中,当合约开始分发资金时,大多数用户直接收到钱。尽管这似乎是可行的,但这是一个考虑不足的解决方案。分发中的addr.send()调用的接收地址可以是一个具有fallback功能的合约,该fallback功可能失败,从而破坏分发。这有效地阻止了所有更多的参与者接受他们的收入。一个更好的解决方案是提供一个用户可以调用的提取功能来收集他们的收入。
...
enum States {
REGISTER,
VOTE,
DETERMINE,
WITHDRAW
}
mapping (address => bool) votes;
uint trueCount;
uint falseCount;
bool winner;
uint winningCompensation;
modifier posttransition() {
_;
goToNextState();
}
function vote(bool val)
public
onlyVoter()
isCurrentStage(State.VOTE)
{
if (votes[msg.sender] == address(0) && msg.value >= VOTE_COST) {
votes[msg.sender] = val;
if (val) {
trueCount++;
} else {
falseCount++;
}
}
}
function determine(bool val)
public
onlyOwner()
isCurrentState(State.VOTE)
pretransition()
posttransition()
{
if (trueCount > falseCount) {
winner = true;
winningCompensation = VOTE_COST + (VOTE_COST*false_votes.length) / true_votes.length;
} else if (falseCount > trueCount) {
winner = false;
winningCompensation = VOTE_COST + (VOTE_COST*true_votes.length) / false_votes.length;
} else {
winningCompensation = VOTE_COST;
}
}
function withdraw()
public
onlyVoter()
isCurrentState(State.WITHDRAW)
{
if (votes[msg.sender] != address(0)) {
if (votes[msg.sender] == winner) {
msg.sender.transfer(winningCompensation);
}
}
}
...
修饰符及功能说明:
- posttransition:在函数调用之后转换到下一个状态
- determine:这个函数与之前的disperse函数非常相似,只是计算赢者和赢者的奖金,而不实际发送任何资金。
- vote:现在,选票被添加到选票的映射中,真实/虚假的计数器增加了。
- withdraw:允许选民收集奖金(如果有的话)。
这样,如果发送失败,它只会在特定调用者的情况下失败,并且不会妨碍其他所有用户收集他们的奖金的能力。
合约库
- Github链接: https://github.com/ethpm
- 仓库链接: https://www.ethpm.com/registry
- 网站: https://www.ethpm.com/
- 文档: https://www.ethpm.com/docs/integration-guide
安全最佳实践
- Github: https://github.com/ConsenSys/smart-contract-best-practices/
- Docs: https://consensys.github.io/smart-contract-best-practices/
也许最基本的软件安全原则是最大限度地重用受信任的代码。在区块链技术中,这甚至可以浓缩成一句格言:“Do not roll your own crypto”。在智能合约的情况下,这意味着从社区彻底审查过的免费可用库中获得尽可能多的好处。
在Ethereum,应用最广泛的解决方案是OpenZeppelin,这是一个丰富的合约库,从ERC20和ERC721 tokens的实现,到crowdsale模型的许多风格,再到在合约中常见的简单行为,如Ownable、Pausable或LimitBalance。这个存储库中的合约经过了广泛的测试,在某些情况下甚至可以作为事实上的标准实现。它们是免费使用的,由Zeppelin和越来越多的外部贡献者一起建造和管理。
同样源自Zeppelin的Zeppelin_os,这是一个开源的服务和工具平台,用于安全地开发和管理智能合约应用程序。zeppelin_os在EVM之上提供了一层,使开发人员可以轻松地启动可升级的DApps,这些DApps链接到一个链上库,它是经过良好测试的、本身可以升级的合约。这些库的不同版本可以在区块链中共存,而一个担保系统允许用户在不同的方向上提出或推动改进。平台还提供了一组用于调试、测试、部署和监控去中心化应用程序的离线工具。
进一步的阅读
应用程序二进制接口(ABI)是强类型的,在编译时是已知的,并且是静态的。所有的合约都有它们打算在编译时调用的任何合约的接口定义。
对Ethereum ABI有更严格和深入的解释可以在网站上找到:https://solidity.readthedocs.io/en/develop/abi-spec.html。该链接包含有关编码的正式规范的详细信息和各种有用的示例。
测试智能合约
测试框架
有几个常用的测试框架(没有特定的顺序):
Truffle Test
作为Truffle框架的一部分,Truffle允许用JavaScript(基于Mocha的)或Solidity编写单元测试。这些测试是针对TestRPC/Ganache运行的。有关编写这些测试的详细信息请参见[truffle]
Embark Framework Testing
Embark与Mocha集成,运行用JavaScript编写的单元测试。这些测试反过来是针对部署在TestRPC/Ganache上的合约运行的。Embark框架自动部署智能合约,并在合约被更改时自动重新部署它们。它还跟踪已部署的合约,并在真正需要时部署合约。containsobject包括一个测试库,它可以在EVM中快速运行和测试您的合约,并使用assert.equal()这样的函数。embark test
将在目录test/
下运行任何测试文件。
DApp
DApp使用原生的Solidity代码(一个名为ds-test的库)和一个名为Ethrun的Parity库来执行Ethereum字节码,然后断言正确性。ds-test库提供用于验证正确性的断言函数,以及用于在控制台中记录数据的事件。
断言函数包括
assert(bool condition)
assertEq(address a, address b)
assertEq(bytes32 a, bytes32 b)
assertEq(int a, int b)
assertEq(uint a, uint b)
assertEq0(bytes a, bytes b)
expectEventsExact(address target)
日志记录事件将日志信息记录到控制台,使它们对调试非常有用。
logs(bytes)
log_bytes32(bytes32)
log_named_bytes32(bytes32 key, bytes32 val)
log_named_address(bytes32 key, address val)
log_named_int(bytes32 key, int val)
log_named_uint(bytes32 key, uint val)
log_named_decimal_int(bytes32 key, int val, uint decimals)
log_named_decimal_uint(bytes32 key, uint val, uint decimals)
Populus
Populus使用python及其自己的链模拟器来运行Solidity编写的合约。单元测试是用Python和pytest库编写的。Populus支持编写专门用于测试的合约。这些合约文件名应该与glob模式Test*.sol
匹配,并位于项目测试目录./tests/
下的任何位置。
框架 | 测试语言 | 测试框架 | 链模拟器 | 网站 |
---|---|---|---|---|
Truffle | Javascript/Solidity | Mocha | TestRPC/Ganache | truffleframework.com |
Embark | Javascript | Mocha | TestRPC/Ganache | embark.readthedocs.io |
DApp | Solidity | ds-test (custom) | Ethrun (Parity) | dapp.readthedocs.io |
Populus | Python | Pytes | Python chain emulator | populus.readthedocs.io |
如果这是您第一次使用geth,可能需要一段时间才能与网络同步。然后将变量设置为:
> var foo = eth(<CONTENTS_OF_ABI_FILE>)
> var byteCode = '0x<CONTENTS_OF_BIN_FILE>)
用上面更多命令的输出填充参数。然后最终部署您的合约:
> var deploy = {from eth.coinbase, data:byteCode, gas:2000000}
> var fooInstance = foo(bar, baz)
On-Blockchain测试
虽然大多数测试不应该在部署的合约上进行,但是合约的行为可以通过Ethereum客户端进行检查。下面的命令可以用来评估一个智能合约的状态。这些命令应该在“geth”终端上键入,尽管任何web3调用也将支持这些命令。
eth.getTransactionReceipt(txhash);
可用于在txhash
获取合同地址。
eth.getCode(contractaddress)
获取在contractaddress中部署的合约的代码。这可以用来验证正确的部署。
eth.getPastLogs(options)
获取位于选项中指定的地址的合约的完整日志。这有助于查看合约调用的历史。
eth.getStorageAt(address, position)
获取位于地址的存储,位置偏移量显示该合约中存储的数据。