$ sudo add-apt-repository ppa:ethereum/ethereum
$ sudo apt update
$ sudo apt install solc
智能合約
我們在 [intro] 中發現,以太坊有兩種不同類型的帳戶:外部擁有帳戶(EOAs)和合約帳戶。EOAs由以太坊以外的軟體(如錢包應用程式)控制。合約帳戶由在以太坊虛擬機(EVM)內運行的軟體控制。兩種類型的帳戶都通過以太坊地址標識。在本節中,我們將討論第二種類型,合約帳戶和控制它們的軟體:智能合約。
什麼是智能合約?
術語smart contract已被用於描述各種不同的事物。在二十世紀九十年代,密碼學家Nick Szabo提出了這個術語,並將其定義為“一組以數字形式規定的承諾,包括各方在其他承諾中履行的協議”。自那時以來,智能合約的概念得到了發展,尤其是在2009年比特幣發明引入了去中心化區塊鏈之後。在本書中,我們使用術語“智能合約”來指代在Ethereum虛擬機環境中確定性的運行的不可變的計算機程式,該虛擬機作為一個去中心化的世界計算機而運轉。
讓我們拆解這個定義:
計算機程式:智能合約只是計算機程式。合約這個詞在這方面沒有法律意義。 不可變的:一旦部署,智能合約的程式碼不能改變。與傳統軟體不同,修改智能合約的唯一方法是部署新實例。 確定性的:智能合約的結果對於運行它的每個人來說都是一樣的,包括調用它們的交易的上下文,以及執行時以太坊區塊鏈的狀態。 EVM上下文:智能合約以非常有限的執行上下文運行。他們可以訪問自己的狀態,調用它們的交易的上下文以及有關最新塊的一些資訊。 去中心化的世界計算機:EVM在每個以太坊節點上作為本地實例運行,但由於EVM的所有實例都在相同的初始狀態下運行併產生相同的最終狀態,因此整個系統作為單臺世界計算機運行。
智能合約的生命週期
智能合約通常以高階語言編寫,例如Solidity。但為了運行,必須將它們編譯為EVM中運行的低級 Bytecode (請參見 [evm])。一旦編譯完成,它們就會隨著轉移到特殊的合約創建地址的交易被部署到以太坊區塊鏈中。每個合約都由以太坊地址標識,該地址源於作為發起帳戶和隨機數的函數的合約創建交易。合約的以太坊地址可以在交易中用作接收者,可將資金發送到合約或調用合約的某個功能。
重要的是,如果合約只有被交易調用時才會運行。以太坊的所有智能合約均由EOA發起的交易執行。合約可以調用另一個合約,其中又可以調用另一個合約,等等。但是這種執行鏈中的第一個合約必須始終由EOA的交易調用。合約永遠不會“自行”運行,或“在後臺運行”。在交易觸發執行,直接或間接地作為合約調用鏈的一部分之前,合約在區塊鏈上實際上是“休眠”的。
交易是 原子性的 atomic,無論他們調用多少合約或這些合約在被調用時執行的是什麼。交易完全執行,僅在交易成功終止時記錄全局狀態(合約,帳戶等)的任何更改。成功終止意味著程式執行時沒有錯誤並且達到執行結束。如果交易由於錯誤而失敗,則其所有效果(狀態變化)都會“回滾”,就好像交易從未運行一樣。失敗的交易仍儲存在區塊鏈中,並從原始帳戶扣除gas成本,但對合約或帳戶狀態沒有其他影響。
合約的程式碼不能更改。然而合約可以被“刪除”,從區塊鏈上刪除程式碼和它的內部狀態(變數)。要刪除合約,你需要執行稱為 SELFDESTRUCT(以前稱為 SUICIDE )的EVM操作碼,該操作碼將區塊鏈中的合約移除。該操作花費“負的gas”,從而激勵儲存狀態的釋放。以這種方式刪除合約不會刪除合約的交易歷史(過去),因為區塊鏈本身是不可變的。但它確實會從所有未來的區塊中移除合約狀態。
以太坊高階語言簡介
EVM是一臺虛擬計算機,運行一種特殊形式的 機器程式碼 ,稱為_EVM Bytecode _,就像你的計算機CPU運行機器程式碼x86_64一樣。我們將在 [evm] 中更詳細地檢查EVM的操作和語言。在本節中,我們將介紹如何編寫智能合約以在EVM上運行。
雖然可以直接在 Bytecode 中編寫智能合約。EVM Bytecode 非常笨重,開發者難以閱讀和理解。相反,大多數以太坊開發人員使用高級符號語言編寫程式和編譯器,將它們轉換為 Bytecode 。
雖然任何高階語言都可以用來編寫智能合約,但這是一項非常繁瑣的工作。智能合約在高度約束和簡約的執行環境(EVM)中運行,幾乎所有通常的用戶界面,作業系統界面和硬體界面都是缺失的。從頭開始構建一個簡約的智能合約語言要比限制通用語言並使其適用於編寫智能合約更容易。因此,為編程智能合約出現了一些專用語言。以太坊有幾種這樣的語言,以及產生EVM可執行 Bytecode 所需的編譯器。
一般來說,程式語言可以分為兩種廣泛的編程範式:分別是宣告式和命令式,也分別稱為“函數式”和“過程式”。在宣告式編程中,我們編寫的函數表示程式的 邏輯 logic,而不是 流程 flow。宣告式編程用於創建沒有 副作用 side effects 的程式,這意味著在函數之外沒有狀態變化。宣告式程式語言包括Haskell,SQL和HTML等。相反,命令式編程就是開發者編寫一套程式的邏輯和流程結合在一起的程式。命令式程式語言包括例如BASIC,C,C++和Java。有些語言是“混合”的,這意味著他們鼓勵宣告式編程,但也可以用來表達一個必要的編程範式。這樣的混合體包括Lisp,Erlang,Prolog,JavaScript和Python。一般來說,任何命令式語言都可以用來在宣告式的範式中編寫,但它通常會導致不雅的程式碼。相比之下,純粹的宣告式語言不能用來寫入一個命令式的範例。在純粹的宣告式語言中,沒有“變數”。
雖然命令式編程更易於編寫和讀取,並且開發者更常用,但編寫按預期方式 準確 執行的程式可能非常困難。程式的任何部分改變狀態的能力使得很難推斷程式的執行,並引入許多意想不到的副作用和錯誤。相比之下,宣告式編程更難以編寫,但避免了副作用,使得更容易理解程式的行為。
智能合約給開發者帶來了很大的負擔:錯誤會花費金錢。因此,編寫不會產生意想不到的影響的智能合約至關重要。要做到這一點,你必須能夠清楚地推斷程式的預期行為。因此,宣告式語言在智能合約中比在通用軟體中扮演更重要的角色。不過,正如你將在下面看到的那樣,最豐富的智能合約語言是命令式的(Solidity)。
智能合約的高級程式語言包括(按大概的年齡排序):
- LLL
-
一種函數式(宣告式)程式語言,具有類似Lisp的語法。這是以太坊智能合約的第一個高階語言,但今天很少使用。
- Serpent
-
一種過程式(命令式)程式語言,其語法類似於Python。也可以用來編寫函數式(宣告式)程式碼,儘管它並不完全沒有副作用。很少被使用。最早由Vitalik Buterin創建。
- Solidity
-
具有類似於JavaScript,C ++或Java語法的過程式(命令式)程式語言。以太坊智能合約中最流行和最常用的語言。最初由Gavin Wood(本書的合著者)創作。
- Vyper
-
最近開發的語言,類似於Serpent,並且具有類似Python的語法。旨在成為比Serpent更接近純粹函數式的類Python語言,但不能取代Serpent。最早由Vitalik Buterin創建。
- Bamboo
-
一種新開發的語言,受Erlang影響,具有明確的狀態轉換並且沒有迭代流(迴圈)。旨在減少副作用並提高可審計性。非常新,很少使用。
如你所見,有很多語言可供選擇。然而,在所有這些中,Solidity是迄今為止最受歡迎的,以至於成為了以太坊甚至是其他類似EVM的區塊鏈的事實上的高階語言。我們將花大部分時間使用Solidity,但也會探索其他高階語言的一些例子,以瞭解其不同的哲學。
用Solidity構建智能合約
來自維基百科:
Solidity是編寫智能合約的“面向合約的”程式語言。它用於在各種區塊鏈平臺上實施智能合約。它由Gavin Wood,Christian Reitwiessner,Alex Beregszaszi,Liana Husikyan,Yoichi Hirai和幾位以前的以太坊核心貢獻者開發,以便在區塊鏈平臺(如以太坊)上編寫智能合約。
Solidity由GitHub上的Solidity項目開發團隊開發並維護:
Solidity項目的主要“產品”是Solidity Compiler(solc),它將用Solidity語言編寫的程式轉換為EVM Bytecode ,並生成其他製品,如應用程式二進制接口(ABI)。Solidity編譯器的每個版本都對應於並編譯Solidity語言的特定版本。
要開始,我們將下載Solidity編譯器的二進制可執行檔案。然後我們會編寫一個簡單的合約。
選擇一個Solidity版本
Solidity遵循一個稱為semantic versioning(https://semver.org/)的版本模型,該模型指定版本號結構為由點分隔的三個數字:MAJOR.MINOR.PATCH。"major"用於對主要的和“向前不兼容”的更改的遞增,“minor”在主要版本之間添加“向前兼容功能“時遞增,“patch”表示錯誤修復和安全相關的更改。
目前,Solidity的版本是0.4.21,其中0.4是主要版本,21是次要版本,之後指定的任何內容都是補丁版本。Solidity的0.5版本主要版本即將推出。
正如我們在[intro]中看到的那樣,你的Solidity程式可以包含一個pragma指令,用於指定與之兼容的Solidity的最小和最大版本,並且可用於編譯你的合約。
由於Solidity正在快速發展,最好始終使用最新版本。
下載/安裝
有許多方法可以用來下載和安裝Solidity,無論是作為二進制發行版還是從源程式碼編譯。你可以在Solidity文件中找到詳細的說明:
在Installing solc on Ubuntu/Debian with apt package manager中,我們將使用 apt package manager 在Ubuntu/Debian作業系統上安裝Solidity的最新二進制版本:
一旦你安裝了 solc,運行以下命令來檢查版本:
$ solc --version solc, the solidity compiler commandline interface Version: 0.4.21+commit.dfe3193c.Linux.g++
根據你的作業系統和要求,還有許多其他方式可以安裝Solidity,包括直接從源程式碼編譯。有關更多資訊,請參閱
開發環境
要在Solidity中開發,你可以在命令行上使用任何文字編輯器和solc。但是,你可能會發現為開發而設計的一些文字編輯器(例如Atom)提供了附加功能,如語法突出顯示和宏,這些功能使Solidity開發變得更加簡單。
還有基於Web的開發環境,如Remix IDE(https://remix.ethereum.org/)和EthFiddle(https://ethfiddle.com/)。
使用可以提高生產力的工具。最後,Solidity程式只是純文字檔案。雖然花哨的編輯器和開發環境可以讓事情變得更容易,但除了簡單的文字編輯器(如vim(Linux / Unix),TextEdit(MacOS)甚至NotePad(Windows)),你無需任何其他東西。只需將程式源程式碼保存為.sol擴展名即可,Solidity編譯器將其識別為Solidity程式。
Writing a simple Solidity program
在[intro]中,我們編寫了我們的第一個Solidity程式,名為Faucet。當我們第一次構建Faucet時,我們使用Remix IDE來編譯和部署合約。在本節中,我們將重新查看,改進和修飾Faucet。
我們的第一次嘗試是這樣的:
// Our first contract is a faucet!
contract Faucet {
// 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 {}
}
從 [make_it_better] 開始,我們將在第一個範例的基礎上構建。
用Solidity編譯器(solc)編譯
現在,我們將使用命令行上的Solidity編譯器直接編譯我們的合約。Solidity編譯器solc提供了多種選項,你可以通過--help參數來查看。
我們使用solc的 --bin 和 --optimize 參數來生成我們範例合約的優化二進制檔案:
$ solc --optimize --bin Faucet.sol ======= Faucet.sol:Faucet ======= Binary: 6060604052341561000f57600080fd5b60cf8061001d6000396000f300606060405260043610603e5763ffffffff7c01000000000000000000000000000000000000000000000000000000006000350416632e1a7d4d81146040575b005b3415604a57600080fd5b603e60043567016345785d8a0000811115606357600080fd5b73ffffffffffffffffffffffffffffffffffffffff331681156108fc0282604051600060405180830381858888f19350505050151560a057600080fd5b505600a165627a7a723058203556d79355f2da19e773a9551e95f1ca7457f2b5fbbf4eacf7748ab59d2532130029
solc產生的結果是一個可以提交給以太坊區塊鏈的十六進制序列化二進制檔案。
以太坊合約應用程式二進制接口(ABI)
在計算機軟體中,應用程式二進制接口(ABI)是兩個程式模塊之間的接口;通常,一個在機器程式碼級別,另一個在用戶運行的程式級別。ABI定義了如何在機器碼中訪問資料結構和功能;不要與API混淆,API以高級的,通常是人類可讀的格式將訪問定義為源程式碼。因此,ABI是將數據編碼到機器碼,和從機器碼解碼數據的主要方式。
在以太坊中,ABI用於編碼EVM的合約調用,並從交易中讀取數據。ABI的目的是定義合約中的哪些函數可以被調用,並描述函數如何接受參數並返回數據。
合約ABI的JSON格式由一系列函數描述(參見[solidity_functions])和事件(參見[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"}]
如你所見,編譯器會生成一個描述由 Faucet.sol 定義的兩個函數的JSON物件。這個JSON物件可以被任何希望在部署時訪問 Faucet 合約的應用程式使用。使用ABI,應用程式(如錢包或DApp瀏覽器)可以使用正確的參數和參數類型構造調用 Faucet 中的函數的交易。例如,錢包會知道要調用函數withdraw,它必須提供名為 withdraw_amount 的 uint256 參數。錢包可以提示用戶提供該值,然後創建一個編碼它並執行withdraw功能的交易。
應用程式與合約進行交互所需的全部內容是ABI以及合約的部署地址。
選擇Solidity編譯器和語言版本
正如我們在 Compiling Faucet.sol with solc 中看到的,我們的 Faucet 合約在Solidity 0.4.21版本中成功編譯。但是如果我們使用了不同版本的Solidity編譯器呢?語言仍然不斷變化,事情可能會以意想不到的方式發生變化。我們的合約非常簡單,但如果我們的程式使用了僅添加到Solidity版本0.4.19中的功能,並且我們嘗試使用0.4.18進行編譯,該怎麼辦?
為了解決這些問題,Solidity提供了一個compiler指令,稱為version pragma,指示編譯器程式需要特定的編譯器(和語言)版本。我們來看一個例子:
pragma solidity ^0.4.19;
Solidity編譯器讀取版本編譯指示,如果編譯器版本與版本編譯指示不兼容,將會產生錯誤。在這種情況下,我們的版本編譯指出,這個程式可以由Solidity編譯器編譯,最低版本為0.4.19。但是,符號^表示我們允許編譯任何minor修訂版在0.4.19之上的,例如0.4.20,但不是0.5.0(這是一個主要版本,不是小修訂版) 。Pragma指令不會編譯為EVM Bytecode 。它們僅由編譯器用來檢查兼容性。
讓我們在我們的 Faucet 合約中添加一條編譯指示。我們將命名新檔案 Faucet2.sol,以便在我們繼續處理這些範例時跟蹤我們的更改:
// Version of Solidity compiler this program was written for
pragma solidity ^0.4.19;
// Our first contract is a faucet!
contract Faucet {
// 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 {}
}
添加版本 pragma 是最佳實踐,因為它避免了編譯器和語言版本不匹配的問題。我們將探索其他最佳實踐,並在本章中繼續改進Faucet合約。
使用Solidity編程
在本節中,我們將看看Solidity語言的一些功能。正如我們在 [intro] 中提到的,我們的第一份合約範例非常簡單,並且在許多方面也存在缺陷。我們將逐漸改進這個例子,同時學習如何使用Solidity。然而,這不會是一個全面的Solidity教程,因為Solidity相當複雜且快速發展。我們將介紹基礎知識,併為你提供足夠的基礎,以便能夠自行探索其餘部分。Solidity的完整文件可以在以下網址找到:
數據類型
首先,我們來看看Solidity中提供的一些基本數據類型:
- boolean (bool)
-
布林值, true 或 false, 以及邏輯操作符 ! (not), && (and), || (or), == (equal), != (not equal).
- 整數 (int/uint)
-
有符號 (int) 和 無符號 (uint) 整數,從 u/int8 到 u/int256以 8 bits 遞增,沒有大小後綴的話,表示256 bits。
- 定點數 (fixed/ufixed)
-
定點數, 定義為 u/fixedMxN,其中 M 是位大小(以8遞增),N 是小數點後的十進制數的個數。
- 地址
-
20字節的以太坊地址。address 物件有 balance (返回帳戶的餘額) 和 transfer (轉移 ether 到該帳戶) 成員方法。
- 字節陣列(定長)
-
固定大小的字節陣列,定義為bytes1到bytes32。
- 字節陣列 (動態)
-
動態大小的字節陣列,定義為bytes或string。
- enum
-
枚舉離散值的用戶定義類型。
- struct
-
包含一組變數的用戶定義的數據容器。
- mapping
-
key => value對的雜湊查找表。
除上述數據類型外,Solidity還提供了多種可用於計算不同單位的字面值:
- 時間單位
-
seconds, minutes, hours, 和 days 可用作後綴,轉換為基本單位 seconds 的倍數。
- 以太的單位
-
wei, finney, szabo, 和 ether 可用作後綴, 轉換為基本單位 wei 的倍數。
到目前為止,在我們的Faucet合約範例中,我們使用uint(這是uint256的別名),用於withdraw_amount變數。我們還間接使用了address變數,它是+ msg.sender+。在本章中,我們將在範例中使用更多數據類型。
讓我們使用單位的倍數之一來提高範例合約Faucet的可讀性。在withdraw函數中,我們限制最大提現額,將數量限制表示為wei,以太的基本單位:
require(withdraw_amount <= 100000000000000000);
這不是很容易閱讀,所以我們可以通過使用單位倍數 ether 來改進我們的程式碼,以ether而不是wei表示值:
require(withdraw_amount <= 0.1 ether);
預定義的全域變數和函數
在EVM中執行合約時,它可以訪問一組較小範圍內的全局物件。這些包括 block,msg 和 tx 物件。另外,Solidity公開了許多EVM操作碼作為預定義的Solidity功能。在本節中,我們將檢查你可以從Solidity的智能合約中訪問的變數和函數。
調用交易/消息上下文
- msg
-
msg物件是啟動合約執行的交易(源自EOA)或消息(源自合約)。它包含許多有用的屬性:
- msg.sender
-
我們已經使用過這個。它代表發起消息的地址。如果我們的合約是由EOA交易調用的,那麼這是簽署交易的地址。
- msg.value
-
與消息一起發送的以太網值。
- msg.gas
-
調用我們的合約的消息中留下的gas量。它已經被棄用,並將被替換為Solidity v0.4.21中的gasleft()函數。
- msg.data
-
調用合約的消息中的數據。
- msg.sig
-
數據的前四個字節,它是函數選擇器。
Note
|
每當合約調用另一個合約時,msg的所有屬性的值都會發生變化,以反映新的調用者的資訊。唯一的例外是在原始msg上下文中運行另一個合約/庫的程式碼的 delegatecall 函數。 |
交易上下文
- tx.gasprice
-
發起調用的交易中的gas價格。
- tx.origin
-
源自(EOA)的交易的完整調用堆疊。
區塊上下文
- block
-
包含有關當前塊的資訊的塊物件。
- block.blockhash(blockNumber)
-
指定塊編號的塊的雜湊,直到之前的256個塊。已棄用,並使用Solidity v.0.4.22中的blockhash()函數替換。
- block.coinbase
-
當前塊的礦工地址。
- block.difficulty
-
當前塊的難度(Proof-of-Work)。
- block.gaslimit
-
當前塊的區塊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。
- address.call()
-
低級調用函數,可以用value,data構造任意消息。錯誤時返回false。
- address.delegatecall()
-
低級調用函數,保持發起調用的合約的msg上下文,錯誤時返回false。
內建函數
- addmod, mulmod
-
模加法和乘法。例如,addmod(x,y,k) 計算 (x + y) % k。
- keccak256, sha256, sha3, ripemd160
-
用各種標準雜湊演算法計算雜湊值的函數。
- ecrecover
-
從簽名中恢復用於簽署消息的地址。
合約的定義
Solidity的主要數據類型是contract物件,它在我們的Faucet範例的頂部定義。與面嚮物件語言中的任何物件類似,合約是一個包含數據和方法的容器。
Solidity提供了另外兩個與合約類似的物件:
- interface
-
接口定義的結構與合約完全一樣,只不過沒有定義函數,它們只是宣告。這種類型的函數宣告通常被稱為 樁 stub,因為它告訴你有關函數的參數和返回值,沒有任何實現。它用來指定合約接口,如果繼承,每個函數都必須在子類中指定。
- library
-
一個庫合約是一個只能部署一次並被其他合約使用的合約,使用delegatecall方法(見地址物件)。
函數
在合約中,我們定義了可以由EOA交易或其他合約調用的函數。在我們的Faucet範例中,我們有兩個函數:withdraw和(未命名的)fallback函數。
函數使用以下語法定義:
function FunctionName([parameters]) {public|private|internal|external} [pure|constant|view|payable] [modifiers] [returns (<return types>)]
我們來看看每個組件:
- FunctionName
-
定義函數的名稱,用於通過交易(EOA),其他合約或同一合約調用函數。每個合約中的一個功能可以定義為不帶名稱的,在這種情況下,它是fallback函數,在沒有指定其他函數時調用該函數。fallback函數不能有任何參數或返回任何內容。
- parameters
-
在名稱後面,我們指定必須傳遞給函數的參數,包括名稱和類型。在我們的Faucet範例中,我們將uint withdraw_amount定義為withdraw函數的唯一參數。
下一組關鍵字 (public, private, internal, external) 指定了函數的可見性:
- public
-
Public是預設的,這些函數可以被其他合約,EOA交易或合約內部調用。在我們的Faucet範例中,這兩個函數都被定義為public。
- external
-
外部函數就像public一樣,但除非使用關鍵字this作為前綴,否則它們不能從合約中調用。
- internal
-
內部函數只能在合約內部"可見",不能被其他合約或EOA交易調用。他們可以被派生合約調用(繼承的)。
- private
-
private函數與內部函數類似,但不能由派生的合約調用(繼承的)。
請記住,術語 internal 和 private 有些誤導性。公共區塊鏈中的任何函數或數據總是可見的,意味著任何人都可以看到程式碼或數據。以上關鍵字僅影響函數的調用方式和時機。
下一組關鍵字(pure, constant, view, payable)會影響函數的行為:
- constant/view
-
標記為view的函數,承諾不修改任何狀態。術語constant是view的別名,將被棄用。目前,編譯器不強制執行view修飾器,只產生一個警告,但這應該成為Solidity v0.5中的強制關鍵字。
- pure
-
純(pure)函數不讀寫任何變數。它只能對參數進行操作並返回數據,而不涉及任何儲存的數據。純函數旨在鼓勵沒有副作用或狀態的宣告式編程。
- payable
-
payable函數是可以接受付款的功能。沒有payable的函數將拒絕收款,除非它們來源於coinbase(挖礦收入)或 作為 SELFDESTRUCT(合約終止)的目的地。在這些情況下,由於EVM中的設計決策,合約無法阻止收款。
正如你在Faucet範例中看到的那樣,我們有一個payable函數(fallback函數),它是唯一可以接收付款的函數。
合約構造和自毀
有一個特殊函數只能使用一次。創建合約時,它還運行 構造函數 constructor function(如果存在),以初始化合約狀態。構造函數與創建合約時在同一個交易中運行。構造函數是可選的。事實上,我們的Faucet範例沒有構造函數。
構造函數可以通過兩種方式指定。到Solidity v.0.4.21,構造函數是一個名稱與合約名稱相匹配的函數:
contract MEContract { function MEContract() { // This is the constructor } }
這種格式的難點在於如果合約名稱被改變並且構造函數名稱沒有改變,它就不再是構造函數了。這可能會導致一些非常令人討厭的,意外的並且很難注意到的錯誤。想象一下,例如,如果構造函數正在為控制目的而設置合約的“所有者”。它不僅可以在創建合約時設置所有者,還可以像正常功能那樣“可調用”,允許任何第三方在合約創建後劫持合約併成為“所有者”。
為了解決構造函數的潛在問題,它基於與合約名稱相同的名稱,Solidity v0.4.22引入了一個constructor關鍵字,它像構造函數一樣運行,但沒有名稱。重命名合約並不會影響構造函數。此外,更容易確定哪個函數是構造函數。看起來像這樣:
pragma ^0.4.22 contract MEContract { constructor () { // This is the constructor } }
總而言之,合約的生命週期始於EOA或其他合約的創建交易。如果有一個構造函數,它將在相同的創建交易中調用,並可以在創建合約時初始化合約狀態。
合約生命週期的另一端是 合約銷燬 contract destruction。合約被稱為SELFDESTRUCT的特殊EVM操作碼銷燬。它曾經是SUICIDE,但由於該詞的負面性,該名稱已被棄用。在Solidity中,此操作碼作為高級內建函數selfdestruct公開,該函數採用一個參數:地址以接收合約帳戶中剩餘的餘額。看起來像這樣:
selfdestruct(address recipient);
添加一個構造函數和selfdestruct到我們的Faucet範例
我們在[intro]中引入的Faucet範例合約沒有任何構造函數或自毀函數。這是永恆的合約,不能從區塊鏈中刪除。讓我們通過添加一個構造函數和selfdestruct函數來改變它。我們可能希望自毀僅由最初創建合約的EOA來調用。按照慣例,這通常儲存在稱為owner的地址變數中。我們的構造函數設置所有者變數,並且selfdestruct函數將首先檢查是否是所有者調用它。
首先是我們的構造函數:
// 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 v.0.4.22中的constructor關鍵字。我們的合約現在有一個名為owner的address類型變數。名稱“owner”不是特殊的。我們可以將這個地址變數稱為“potato”,仍然以相同的方式使用它。名稱owner只是簡單明瞭的目的和目的。
然後,作為合約創建交易的一部分運行的constructor函數將msg.sender的地址分配給owner變數。我們使用 withdraw 函數中的 msg.sender 來 標識提款請求的來源。然而,在構造函數中,msg.sender是簽署合約創建交易的EOA或合約地址。這是事實,因為這是一個構造函數:它只運行一次,並且僅作為合約創建交易的結果。
好的,現在我們可以添加一個函數來銷燬合約。我們需要確保只有所有者才能運行此函數,因此我們將使用require語句來控制訪問。看起來像這樣:
// Contract destructor function destroy() public { require(msg.sender == owner); selfdestruct(owner); }
如果其他人用 owner 以外的地址調用 destroy 函數,則將失敗。但是,如果構造函數儲存在 owner 中的地址調用它,合約將自毀,並將剩餘餘額發送到 owner 地址。
函數修飾器
Solidity提供了一種稱為函數修飾器的特殊類型的函數。通過在函數宣告中添加修飾器名稱,可以將修飾器應用於函數。修飾器函數通常用於創建適用於合約中許多函數的條件。我們已經在我們的destroy函數中有一個訪問控制語句。讓我們創建一個表達該條件的函數修飾器:
modifier onlyOwner { require(msg.sender == owner); _; }
在 onlyOwner function modifier 中,我們看到函數修飾器的宣告,名為onlyOwner。此函數修飾器為其修飾的任何函數設置條件,要求儲存為合約的owner的地址與交易的msg.sender的地址相同。這是訪問控制的基本設計模式,只允許合約的所有者執行具有onlyOwner修飾器的任何函數。
你可能已經注意到我們的函數修飾器在其中有一個特殊的語法“佔位符”,下劃線後跟分號(_;)。此佔位符由正在修飾的函數的程式碼替換。本質上,修飾器“修飾”修飾過的函數,將其程式碼置於由下劃線字符標識的位置。
要應用修飾器,請將其名稱添加到函數宣告中。可以將多個修飾器應用於一個函數,作為逗號分隔的列表,以宣告的順序應用。
讓我們重新編寫destroy函數來使用onlyOwner修飾器:
function destroy() public onlyOwner { selfdestruct(owner); }
函數修飾器的名稱(onlyOwner)位於關鍵字public之後,並告訴我們destroy函數由onlyOwner修飾器修飾。基本上你可以把它寫成:“只有所有者才能銷燬這份合約”。實際上,生成的程式碼相當於由onlyOwner “包裝” 的destroy程式碼。
函數修飾器是一個非常有用的工具,因為它們允許我們為函數編寫前提條件並一致地應用它們,使程式碼更易於閱讀,因此更易於審計安全問題。它們最常用於訪問控制,如範例中的“function_modifier_onlyowner”,但功能很多,可用於各種其他目的。
在修飾函數內部,可以訪問被修飾的函數的的所有可見符號(變數和參數)。在這種情況下,我們可以訪問在合約中宣告的owner變數。但是,反過來並不正確:你無法訪問修飾函數中的任何變數。
合約繼承
Solidity的合約物件支持 繼承,這是一種用附加功能擴展基礎合約的機制。要使用繼承,請使用關鍵字is指定父合約:
contract Child is Parent { }
通過這個構造,Child合約繼承了Parent的所有方法,功能和變數。Solidity還支持多重繼承,可以在關鍵字is之後用逗號分隔的合約名稱指定多重繼承:
contract Child is Parent1, Parent2 { }
合約繼承使我們能夠以實現模塊化,可擴展性和重用的方式編寫我們的合約。我們從簡單的合約開始,實現最通用的功能,然後通過在更具體的合約中繼承這些功能來擴展它們。
在我們的Faucet合約中,我們引入了構造函數和析構函數,以及為構建時指定的owner提供的訪問控制。這些功能非常通用:許多合約都有它們。我們可以將它們定義為通用合約,然後使用繼承將它們擴展到Faucet合約。
我們首先定義一個基礎合約owned,它擁有一個owner變數,並在合約的構造函數中設置:
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 合約可以使用在owned中定義的ownOwner函數修飾器。它間接地也使用owner address變數和owned中定義的構造函數。繼承使每個合約變得更簡單,並專注於其類的特定功能,使我們能夠以模組化的方式管理細節。
現在我們可以進一步擴展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,繼而繼承owned,Faucet合約現在具有構造函數和銷燬函數以及定義的owner。這些功能與Faucet中的功能相同,但現在我們可以在其他合約中重用這些功能而無需再次寫入它們。程式碼重用和模塊化使我們的程式碼更清晰,更易於閱讀,並且更易於審計。
錯誤處理(assert, require, revert)
合約調用可以終止並返回錯誤。Solidity中的錯誤由四個函數處理:assert, require, revert, 和 throw(現在已棄用)。
當合約終止並出現錯誤時,如果有多個合約被調用,則所有狀態變化(變數,餘額等的變化)都會恢復,直至合約調用鏈的源頭。這確保交易是原子的,這意味著它們要麼成功完成,要麼對狀態沒有影響,並完全恢復。
assert和require函數以相同的方式運行,如果條件為假,則評估條件並停止執行並返回錯誤。按照慣例,當結果預期為真時使用assert,這意味著我們使用assert來測試內部條件。相比之下,在測試輸入(例如函數參數或交易欄位)時使用require,設置我們對這些條件的期望。
我們在函數修飾器onlyOwner中使用了require來測試消息發送者是合約的所有者:
require(msg.sender == owner);
require 函數充當守護條件,阻止執行函數的其餘部分,並在不滿足時產生錯誤。
從Solidity v.0.4.22開始,require還可以包含有用的文字消息,可用於顯示錯誤的原因。錯誤消息記錄在交易日誌中。所以我們可以通過在我們的require函數中添加一條錯誤消息來改進我們的程式碼:
require(msg.sender == owner, "Only the contract owner can call this function");
revert 和 throw 函數,停止執行合約並還原任何狀態更改。throw函數已過時,將在未來版本的Solidity中刪除 - 你應該使用revert代替。revert函數還可以將作為唯一參數的錯誤消息記錄在交易日誌中。
無論我們是否明確檢查它們,合約中的某些條件都會產生錯誤。例如,在我們的Faucet合約中,我們不檢查是否有足夠的ether來滿足提款請求。這是因為如果沒有足夠的餘額進行轉帳,transfer函數將失敗並恢復交易:
msg.sender.transfer(withdraw_amount);
但是,最好明確檢查,並在失敗時提供明確的錯誤消息。我們可以通過在轉移之前添加一個require語句來實現這一點:
require(this.balance >= withdraw_amount, "Insufficient balance in faucet for withdrawal request"); msg.sender.transfer(withdraw_amount);
像這樣的其他錯誤檢查程式碼會略微增加gas消耗,但它比不檢查提供了更好的錯誤報告。在gas量和詳細錯誤檢查之間取得適當的平衡是你需要根據合約的預期用途來決定的。在為測試網路設計的Faucet的情況下,即使額外報告成本更高,我們也不冒險犯錯。也許對於一個主網合約,我們會選擇節約gas用量。
事件(Events)
事件是便於生產交易日誌的Solidity構造。當一個交易完成(成功與否)時,它會產生一個 交易收據 transaction receipt,就像我們在 [evm] 中看到的那樣。交易收據包含log條目,用於提供有關在執行交易期間發生的操作的資訊。事件是用於構造這些日誌的Solidity高級物件。
事件在輕量級客戶端和DApps中特別有用,它可以“監視”特定事件並將其報告給用戶界面,或對應用程式的狀態進行更改以反映底層合約中的事件。
事件物件接收序列化的參數並記錄在區塊鏈的交易日誌中。你可以在參數之前應用關鍵字indexed,以使其值作為索引表(雜湊表)的一部分,可以由應用程式搜索或過濾。
到目前為止,我們還沒有在我們的Faucet範例中添加任何事件,所以讓我們來做。我們將添加兩個事件,一個記錄任何提款,一個記錄任何存款。我們將分別稱這些事件Withdrawal和Deposit。首先,我們在Faucet合約中定義事件:
contract Faucet is mortal { event Withdrawal(address indexed to, uint amount); event Deposit(address indexed from, uint amount); [...] }
我們選擇將地址標記為indexed,以允許任何訪問我們的Faucet的用戶界面中搜索和過濾。
接下來,我們使用 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 合約現在看起來像:
link:code/Solidity/Faucet8.sol[]
// Version of Solidity compiler this program was written for
pragma solidity ^0.4.22;
contract owned {
address owner;
// Contract constructor: set owner
constructor() {
owner = msg.sender;
}
// Access control modifier
modifier onlyOwner {
require(msg.sender == owner, "Only the contract owner can call this function");
_;
}
}
contract mortal is owned {
// Contract destructor
function destroy() public onlyOwner {
selfdestruct(owner);
}
}
contract Faucet is mortal {
event Withdrawal(address indexed to, uint amount);
event Deposit(address indexed from, uint amount);
// Give out ether to anyone who asks
function withdraw(uint withdraw_amount) public {
// Limit withdrawal amount
require(withdraw_amount <= 0.1 ether);
require(this.balance >= withdraw_amount,
"Insufficient balance in faucet for withdrawal request");
// Send the amount to the address that requested it
msg.sender.transfer(withdraw_amount);
emit Withdrawal(msg.sender, withdraw_amount);
}
// Accept any incoming amount
function () public payable {
emit Deposit(msg.sender, msg.value);
}
}
捕捉事件
好的,所以我們已經建立了我們的合約來發布事件。我們如何看到交易的結果並“捕捉”事件?web3.js庫提供一個資料結構,作為包含交易日誌的交易的結果。在那裡,我們可以看到交易產生的事件。
讓我們使用truffle在修訂的Faucet合約上運行測試交易。按照 [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 ] } }
用deployed()函數獲得部署的合約後,我們執行兩個交易。第一筆交易是一筆存款(使用send),在交易日誌中發出Deposit事件:
Deposit { from: '0x627306090abab3a6e1400e9345bc60c78a8bef57', amount: BigNumber { s: 1, e: 18, c: [ 10000 ] } }
接下來,我們使用withdraw函數進行提款。這會發出Withdrawal事件:
Withdrawal { to: '0x627306090abab3a6e1400e9345bc60c78a8bef57', amount: BigNumber { s: 1, e: 17, c: [ 1000 ] } }
為了獲得這些事件,我們查看了作為結果(res)返回的logs陣列。第一個日誌條目(logs[0])包含logs[0].event的事件名稱和logs[0].args的事件參數。通過在控制台上顯示這些資訊,我們可以看到發出的事件名稱和事件參數。
事件是一種非常有用的機制,不僅適用於合約內通信,還適用於開發過程中的調試。
調用其他合約 (call, send, delegatecall, callcode)
在合約中調用其他合約是非常有用但有潛在危險的操作。我們將研究你可以實現的各種方法並評估每種方法的風險。
創建一個新的實例
調用另一份合約最安全的方法是你自己創建其他合約。這樣,你就可以確定它的接口和行為。要做到這一點,你可以簡單地使用關鍵字new來實例化它,就像任何物件導向的語言一樣。在Solidity中,關鍵字new將在區塊鏈上創建合約並返回一個可用於引用它的物件。假設你想從另一個名為Token的合約中創建並調用Faucet合約:
contract Token is mortal { Faucet _faucet; constructor() { _faucet = new Faucet(); } }
這種合約建造機制確保你知道合約的確切類型及其接口。合約Faucet必須在Token範圍內定義,如果定義位於另一個檔案中,你可以使用import語句來執行此操作:
import "Faucet.sol" contract Token is mortal { Faucet _faucet; constructor() { _faucet = new Faucet(); } }
new關鍵字還可以接受可選參數來指定創建時傳輸的ether+值+以及傳遞給新合約構造函數的參數(如果有):
import "Faucet.sol" contract Token is mortal { Faucet _faucet; constructor() { _faucet = (new Faucet).value(0.5 ether)(); } }
如果我們賦予創建的Faucet一些ether,我們也可以調用Faucet函數,它們就像方法調用一樣操作。在這個例子中,我們從Token的destroy函數中調用Faucet的destroy函數:
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) } }
在這裡,我們將地址作為參數提供給構造函數,並將其作為Faucet物件進行轉換。這比以前的機制風險大得多,因為我們實際上並不知道該地址是否實際上是Faucet物件。當我們調用withdraw時,我們假設它接受相同的參數並執行與我們的Faucet宣告相同的程式碼,但我們無法確定。就我們所知,在這個地址的withdraw函數可以執行與我們所期望的完全不同的事情,即使它的命名相同。因此,使用作為輸入傳遞的地址並將它們轉換成特定的物件中比自己創建合約要危險得多。
原始調用, delegatecall
Solidity為調用其他合約提供了一些更“低級”的功能。它們直接對應於具有相同名稱的EVM操作碼,並允許我們手動構建合約到合約的調用。因此,它們代表了調用其他合約最靈活和最危險的機制。
以下是使用 call 方法的相同範例:
contract Token is mortal { constructor(address _faucet) { _faucet.call("withdraw", 0.1 ether); } }
正如你所看到的,這種類型的call,是一個函數的盲 blind調用,就像構建一個原始交易一樣,只是在合約的上下文中。它可能會使我們的合約面臨一些安全風險,最重要的是 可重入性 reentrancy,我們將在 [reentrancy] 中更詳細地討論。如果出現問題,call函數將返回false,所以我們可以評估返回值以進行錯誤處理:
contract Token is mortal { constructor(address _faucet) { if !(_faucet.call("withdraw", 0.1 ether)) { revert("Withdrawal from faucet failed"); } } }
call的另一個變體是delegatecall,它取代了更危險的callcode。callcode方法很快就會被棄用,所以不應該使用它。
正如地址物件中提到的,delegatecall不同於call,因為msg上下文不會改變。例如,call 將 msg.sender 的值更改為發起調用的合約,而delegatecall保持與發起調用的合約中的msg.sender相同。基本上,delegatecall在當前合約的上下文中運行另一個合約的程式碼。它最常用於從library調用程式碼。
應該謹慎使用delegatecall。它可能會有一些意想不到的效果,特別是如果你調用的合約不是作為庫設計的。
讓我們使用範例合約來演示call和delegatecall用於調用庫和合約的各種調用語義。我們使用一個事件來記錄每個調用的來源,並根據調用類型瞭解調用上下文如何變化:
link:code/truffle/CallExamples/contracts/CallExamples.sol[]
pragma solidity ^0.4.22;
contract calledContract {
event callEvent(address sender, address origin, address from);
function calledFunction() public {
emit callEvent(msg.sender, tx.origin, this);
}
}
library calledLibrary {
event callEvent(address sender, address origin, address from);
function calledFunction() public {
emit callEvent(msg.sender, tx.origin, this);
}
}
contract caller {
function make_calls(calledContract _calledContract) public {
// Calling the calledContract and calledLibrary directly
_calledContract.calledFunction();
calledLibrary.calledFunction();
// Low level calls using the address object for calledContract
require(address(_calledContract).call(bytes4(keccak256("calledFunction()"))));
require(address(_calledContract).delegatecall(bytes4(keccak256("calledFunction()"))));
}
}
我們的主要合約是caller,它調用庫 calledLibrary 和合約 calledContract。被調用的庫和合約有相同的函數 calledFunction,發送calledEvent事件。calledEvent事件記錄三個數據:msg.sender, tx.origin, 和 this。每次調用calledFunction時,都會有不同的上下文(不同的 msg.sender)取決於它是直接調用還是通過 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_calls函數並傳遞calledContract的地址,然後捕獲不同調用發出的四個事件。查看make_calls函數,讓我們逐步瞭解每一步。
第一個調用:
_calledContract.calledFunction();
在這裡,我們直接調用calledContract.calledFunction,使用稱為callFunction的高級ABI。發出的事件是:
sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f', origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57', from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10'
如你所見,msg.sender是caller合約的地址。tx.origin是我們的錢包web3.eth.accounts[0]的地址,錢包將交易發送給caller。該事件由calledContract發出,我們從事件中的最後一個參數可以看到。
make_calls中的下一次調用是對庫的調用:
calledLibrary.calledFunction();
它看起來與我們調用合約的方式完全相同,但行為非常不同。我們來看看發出的第二個事件:
sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57', origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57', from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f'
這一次,msg.sender不是caller的地址。相反,它是我們錢包的地址,與交易來源相同。這是因為當你調用一個庫時,這個調用總是delegatecall並且在調用者的上下文中運行。所以,當calledLibrary程式碼運行時,它繼承caller的執行上下文,就好像它的程式碼在caller中運行一樣。變數this(在發出的事件中顯示為from)是caller的地址,即使它是從calledLibrary內部訪問的。
接下來的兩個調用,使用低級call和delegatecall,驗證我們的期望,發出與我們剛剛看到的事件相同的結果。
Gas 的考慮
Gas在 [gas] 一節中有更詳細的描述,在智能合約編程中是一個非常重要的考慮因素。gas是限制以太坊允許交易消耗的最大計算量的資源。如果在計算過程中超過了gas限制,則會發生以下一系列事件:
-
引發“out of gas”異常。
-
函數執行前的合約狀態被恢復。
-
全部gas作為交易費用交給礦工,不予退還。
由於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單位。
為了獲得網路的 gas價格 可以使用:
var gasPrice = web3.eth.getGasPrice();
然後估算 gas成本
var gasCostInEther = web3.fromWei((gasEstimate * gasPrice), 'ether');
讓我們應用我們的天然氣估算函數來估計我們的Faucet範例的天然氣成本,使用此書程式碼庫中的程式碼:
code/truffle/FaucetEvents
我們以開發模式啟動truffle,並執行一個JavaScript檔案gas_estimates.js,其中包含:
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成本評估作為開發工作流程的一部分進行,以避免將合約部署到主網時出現意外。
安全考慮
在編寫智能合約時,安全是最重要的考慮因素之一。與其他程式一樣,智能合約將完全按寫入的內容執行,這並不總是開發者所期望的。此外,所有智能合約都是公開的,任何用戶都可以通過創建交易來與他們進行交互。任何漏洞都可以被利用,損失幾乎總是無法恢復。
在智能合約編程領域,錯誤代價高昂且容易被利用。因此,遵循最佳實踐並使用經過良好測試的設計模式至關重要。
防禦性編程 Defensive programming是一種編程風格,特別適用於智能合約編程,具有以下特點:
- 極簡/簡約
-
複雜性是安全的敵人。程式碼越簡單,程式碼越少,發生錯誤或無法預料的效果的可能性就越小。當第一次參與智能合約編程時,開發人員試圖編寫大量程式碼。相反,你應該仔細查看你的智能合約程式碼,並嘗試找到更少的方法,使用更少的程式碼行,更少的複雜性和更少的“功能”。如果有人告訴你他們的項目產生了“數千行程式碼”,那麼你應該質疑該項目的安全性。更簡單更安全。
- 程式碼重用
-
儘可能不要“重新發明輪子”。如果庫或合約已經存在,可以滿足你的大部分需求,請重新使用它。在你自己的程式碼中,遵循DRY原則:不要重複自己。如果你看到任何程式碼片段重複多次,請問自己是否可以將其作為函數或庫進行編寫並重新使用。已被廣泛使用和測試的程式碼可能比你編寫的任何新程式碼更安全。謹防“Not-Invented-Here”的態度,如果你試圖通過從頭開始構建“改進”某個功能或組件。安全風險通常大於改進值。
- 程式碼質量
-
智能合約程式碼是無情的。每個錯誤都可能導致經濟損失。你不應該像通用編程一樣對待智能合約編程。相反,你應該採用嚴謹的工程和軟體開發方法論,類似於航空航天工程或類似的不容樂觀的工程學科。一旦你“啟動”你的程式碼,你就無法解決任何問題。
- 可讀性/可審核性
-
你的程式碼應易於理解和清晰。閱讀越容易,審計越容易。智能合約是公開的,因為任何人都可以對 Bytecode 進行逆向工程。因此,你應該使用協作和開源方法在公開場合開發你的工作。你應該編寫文件良好,易於閱讀的程式碼,遵循作為以太坊社區一部分的樣式約定和命名約定。
- 測試覆蓋
-
測試你可以測試的所有內容。智能合約運行在公共執行環境中,任何人都可以用他們想要的任何輸入執行它們。你絕不應該假定輸入(比如函數參數)是正確的,並且有一個良性的目的。測試所有參數以確保它們在預期的範圍內並且格式正確。
常見的安全風險
智能合約開發者應該熟悉許多最常見的安全風險,以便能夠檢測和避免使他們面臨這些風險的編程模式。
重入 Re-entrancy
重入是編程中的一種現象,函數或程式被中斷,然後在先前調用完成之前再次調用。在智能合約編程的情況下,當合約A調用合約B中的一個函數時,可能會發生重入,合約B又調用合約A中的相同函數,導致遞迴執行。在合約狀態在關鍵性調用結束之後才更新的情況下,這可能是特別危險的。
為了理解這一點,想象一下通過錢包合約調用銀行合約的提現操作。合約A在合約B中調用提現功能,試圖提取金額X。這種情況將涉及以下操作:
-
合約B檢查A是否有必要的餘額來提取X。
-
B將X傳送到A的地址(運行A的payable fallback函數)
-
B更新A的餘額以反映此次提現
無論何時向合約發送付款(如本例中),接收方合約(A)都有機會執行payable函數,例如預設的fallback函數。但是,惡意攻擊者可以利用這種執行。想象一下,在A的payable fallback中,合約A_再次_調用B銀行的提款功能。B的提現功能現在將經歷重入,因為現在相同的初始交易正在引發迴圈調用。
"(1) A 調用 B (2) B 調用 A 的 payable 函數 (1) A 再次調用 B "
在B的退出提現函數的第二次迭代中,B將再次檢查A是否有可用餘額。由於步驟3(其更新了A的餘額)尚未執行,所以對於B來說,無論該函數被重新調用多少次,A仍然具有可用資金來提現。只要有gas可以繼續運行,就可以重複該迴圈。當A檢測到gas量不足時,它可以在payable函數中停止呼叫B. B將最終執行步驟3,從A的餘額中扣除X. 然而,這時,B可能已經執行了數百次轉帳,並且只扣除了一次費用。在這次襲擊中,A有效地洗劫了B的資金。
這個漏洞因其與DAO攻擊的相關性而特別出名。用戶利用了這樣一個事實,即在調用轉移並提取價值數百萬美元的ether後,合約中的餘額才發生變化。
為了防止重入,最好的做法是讓開發者使用Checks-Effects-Interactions模式,在進行調用之前應用函數調用的影響(例如減少餘額)。在我們的例子中,這意味著切換步驟3和2:在傳輸之前更新用戶的餘額。
在以太坊,這是完全沒問題的,因為交易的所有影響都是原子的,這意味著在沒有支付給用戶的情況下更新餘額是不可能的。要麼都發生,要麼拋出異常,都不會發生。這樣可以防止重入攻擊,因為所有後續調用原始提現函數的操作都會遇到正確的修改後餘額。通過切換這兩個步驟,可以防止A的提現金額超過其餘額。
設計模式
任何編程範式的軟體開發人員通常都會遇到以行為,結構,交互和創建為主題的重複設計挑戰。通常這些問題可以概括並重新應用於未來類似性質的問題。當給定正式結構時,這些概括稱為設計模式。智能合約有自己的一系列重複出現的設計問題,可以使用下面描述的一些模式來解決。
在智能合約的發展中存在著無數的設計問題,因此無法討論所有這些問題 這裡。因此,本節將重點討論智能合約設計中最常見的三類問題分類:訪問控制(access control),狀態流(state flow)和資金支出(fund disbursement)。
在本節中,我們將制定一份合約,最終將包含所有這三種設計模式。該合約將運行投票系統,允許用戶對“真相”進行投票。該合約將提出一項宣告,例如“小熊隊贏得世界系列賽”。或者“紐約市正在下雨”,然後用戶會有機會選擇真或假。如果大多數參與者投票贊成"真"合約就認為該宣告為真,如果大多數參與者投票贊成“假”,則合約將認為該宣告為“假”。為了激勵真實性,每次投票必須向合約發送100 ether,而失敗的少數派出的資金將分給大多數。大多數參與者將從少數人中獲得他們的部分獎金以及他們的初始投資。
這個“真相投票”系統實際上是Gnosis的基礎,Gnosis是一個建立在以太坊之上的預測工具。有關Gnosis的更多資訊,請訪問:https://gnosis.pm/
訪問控制 Access control
訪問控制限制哪些用戶可以調用合約功能。例如,真相投票合約的所有者可能決定限制那些可以參與投票的人。 為了達到這個目標,合約必須施加兩個訪問限制:
-
只有合約的所有者可以將新用戶添加到“允許的選民”列表中
-
只有允許的選民可以投票
Solidity函數修飾器提供了一個簡潔的方式來實現這些限制。
_Note: 以下範例在修改器主體內使用下劃線分號。這是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: 這個修飾器可以修飾一個函數,使得函數只能被地址與owner相同的發送者調用。
-
onlyVoter: 這個修飾器可以修飾一個函數,使得函數只能被已登記的選舉人調用。
-
addVoter(voter): 此函數用於將選民添加到選民列表。該功能使用onlyOwner修飾器,因此只有該合約的所有者可以調用它。
-
vote(val): 這個函數被投票者用來對所提出的命題投下真或假。它用onlyVoter修飾器裝飾,所以只有已登記的選民可以調用它。
狀態流 State flow
許多合約將需要一些操作狀態的概念。合約的狀態將決定合約的行為方式以及在給定的時間點提供的操作。讓我們回到我們的真實投票系統來獲得更具體的例子。
我們投票系統的運作可以分為三個不同的狀態。
-
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: 計算大多數以及相應的瓜分獎金的功能。只有owner可以調用這個函數來正式結束投票。
-
startVote: 所有者可用於開始投票的功能。
注意到允許所有者隨意關閉投票流程可能會導致合約的濫用很重要。在更真實的實現中,投票期應在公眾理解的時間段後結束。對於這個例子,這沒問題。
現在增加的內容確保只有在owner決定開始投票階段時才允許投票,用戶只能在投票前由owner註冊,並且在投票結束後才能分配資金。
提現 Withdraw
許多合約將為用戶從中提取資金提供一些方法。在我們的範例中,屬於大多數的用戶在合約開始分配資金時直接接收資金。雖然這看起來有效,但它是一種欠考慮的解決方案。在disperse中addr.send()調用的接收地址可以是一個合約,具有一個會失敗的fallback函數,會打斷disperse。這有效地阻止了更多的參與者接收他們的收入。 一個更好的解決方案是提供一個用戶可以調用來收取收入的提款功能。
...
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: 投票現在被添加到votes mapping,並使用真/假計數器。
-
withdraw: 允許投票者提取勝利果實(如果有)。
這樣,如果發送失敗,則只在一個特定的調用者上失敗,不影響其他用戶提取他們的勝利果實。
合約庫
Github link: https://github.com/ethpm
Repository link: https://www.ethpm.com/registry
Website: https://www.ethpm.com/
Documentation: https://www.ethpm.com/docs/integration-guide
安全最佳實踐
也許最基本的軟體安全原則是最大限度地重用可信程式碼。在區塊鏈技術中,這甚至會凝結成一句格言:“Do not roll your own crypto”。就智能合約而言,這意味著儘可能多地從經社區徹底審查的免費庫中獲益。
在Ethereum中,使用最廣泛的解決方案是https://openzeppelin.org/[OpenZeppelin]套件,從ERC20和ERC721的Token實現,到眾多眾包模型,到常見於“Ownable”,“Pausable”或“LimitBalance”等合約中的簡單行為。該儲存庫中的合約已經過廣泛的測試,並且在某些情況下甚至可以用作de facto標準實現。它們可以免費使用,並且由https://zeppelin.solutions[Zeppelin]和不斷增長的外部貢獻者列表構建和修復。
同樣來自Zeppelin的是https://zeppelinos.org/[zeppelin_os],一個用於安全地開發和管理智能合約應用程式的服務和工具的開源平臺。zeppelin_os在EVM之上提供了一個層,使開發人員可以輕鬆發佈可升級的DApp,它們與經過良好測試的可自行升級的鏈上合約庫鏈接。這些庫的不同版本可以共存於區塊鏈中,憑證系統允許用戶在不同方向上提出或推動改進。該平臺還提供了一套用於調試,測試,部署和監控DApp的脫鏈工具。
進一步閱讀
應用程式二進制接口(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框架自動部署智能合約,並在合約被更改時自動重新部署它們。它還跟蹤已部署的合約,並在真正需要時部署合約。Embark包括一個測試庫,它可以在EVM中快速運行和測試你的合約,並使用assert.equal()等函數。Embark測試將在目錄測試下運行任何測試檔案。
- DApp
-
DApp使用本地Solidity程式碼(一個名為ds-test的庫)和一個Parity構建的Rust庫(稱為Ethrun)執行以太坊 Bytecode ,然後斷言正確性。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編寫的合約。單元測試是用pytest庫編寫的。Populus支持專門用於測試的書面合約。這些合約檔案名應該與glob模式
Test*.sol
匹配,並且位於項目測試目錄./tests/
下的任何位置。
Framework |
Test Language(s) |
Testing Framework |
Chain Emulator |
Website |
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'終端輸入,儘管任何web3調用也會支持這些命令。
eth.getTransactionReceipt(txhash);
可用於獲得在txhash
處的合約地址。
eth.getCode(contractaddress)
獲取部署在contractaddress
的合約程式碼。這可以用來驗證正確的部署。
eth.getPastLogs(options)
獲取位於地址的合約的完整日誌,在選項中指定。這有助於查看合約調用的歷史記錄。
eth.getStorageAt(address, position)
獲取位於 address 的儲存,並使用 position 的偏移量顯示該合約中儲存的數據。