《精通以太坊》-智能合約

原文:Smart contracts

正如我們在[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對象,包含typenameinputsoutputsconstant和payable的字段。事件描述對象具有typenameinputsanonymous字段。

我們使用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。該方案將涉及以下行為:

  1. 合約B檢查A是否有提取X所需的余額
  2. B將X轉到A的地址(運行一個payable fallback函數)
  3. 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/

訪問控制

訪問控制限制哪些用戶可以調用合約函數。例如,真實投票合約的所有人可以決定限制那些可以參與投票的人。為了實現這一目標,合約必須實施兩個訪問限制:

  1. 只有合約的所有者可以將新用戶添加到“允許投票者”列表中
  2. 只有被允許的選民才可以投票

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修飾符,所以只有注冊選民可以調用它。

狀態流

許多合約需要一些操作狀態的概念。合約的狀態將決定合約如何運行,以及在給定的時間點它將提供什么操作。讓我們回到我們的真相投票系統,尋找一個更具體的例子。

我們的投票系統的運作可分為三種不同的狀態。

  1. Register:服務已經創建,所有者現在可以添加投票者。
  2. Vote:所有的選民都投了票。
  3. 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:允許選民收集獎金(如果有的話)。

這樣,如果發送失敗,它只會在特定調用者的情況下失敗,并且不會妨礙其他所有用戶收集他們的獎金的能力。

合約庫

安全最佳實踐

也許最基本的軟件安全原則是最大限度地重用受信任的代碼。在區塊鏈技術中,這甚至可以濃縮成一句格言:“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)

獲取位于地址的存儲,位置偏移量顯示該合約中存儲的數據。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容