以太坊智能合約開發基本知識
本文將介紹在以太坊開發中一些基本的知識。我們會介紹到以太坊的基本原理,包含其網路與節點,用Solidity開發智能合約的基本原理。還有以太坊相關的協議等議題。
以太坊基礎
在這一節我們會講述到所有關於以太坊與EVM(Ethereum Virtual Machine)的基礎介紹。
甚麼是DApps?
分佈式應用程序(Distributed Application),簡稱DApps,這是一種可以直接運作在區塊鏈中的應用程序。是一種分佈式的方式。它在區塊鏈中的作用有很多,從簡單的p2p價值轉移(現今大部分是加密貨幣)邏輯運作到複雜的代幣結構。
因為其經濟特徵與邏輯功能運作在同一個層面(也就是區塊鏈中),它讓價值轉移變得更容易。此外我們不用管理其底層的硬體與其配置(但要付手續費,這後面我們會提到),就可以將我們要運作的業務邏輯放在區塊鏈中。
以太坊與EVM的基本概念
所謂的EVM白話一點說就是一個沙盒內的運作的東西,並且這個沙盒在每一個區塊鏈中的每台電腦都存在。如果用技術術語來說的話,它就是一個在電腦中運作的容器。EVM是一個圖靈完備(Turing complete)並且是一個以基於交易的狀態機器(transaction-based state machine)。區塊鏈中的節點,只有挖到礦的電腦才能拿到回報,其他節點只稽核這個礦工有沒有老實的驗證交易。
區塊鏈是一種確定性的東西,意思是想同的輸入永遠產生相同的輸出。
哈希(Hashing)
區塊鏈使用hashing的方式將資料(不管它的大小是如何)都可以對應成固定大小的資料, 並且我們有大量可用的哈希函數。而以太坊使用所謂的 Keccak,它跟 SHA-3 幾乎相同,但在輸入填充(input padding)方面略有不同,因此它們不可互換使用。
而對於挖礦,則使用了一種稱為 Dagger-Hashimoto 的混合方法,它結合了 DAG(有向無環圖) 方法來產生資料,並使用了 Hashimoto 的哈希、移位(shift)和取模(modulo)方法。 這種 Dagger-Hashimoto 方法已經演變成我們現在使用的 Ethash,它是以太坊中使用的PoW方式(但現在已轉換成PoS了)。
以太坊的票面價值
在以太坊中有幾種價值換算的單位是我們需要注意的。一個以太幣(1 Ether)是大家比較熟悉的。但我們還可以把1 Ether換成更小的單位,就像是一元台幣等於10角一樣。例如,1 Ether = 10的18次方 Wei。以下為以太坊中的單位換算:
1 Ether = 1000000000000000000 Wei (10¹⁸)
1 Ether = 1000000000000000 Kwei, Ada, Femtoether (10¹⁵)
1 Ether = 1000000000000 Mwei, Babbage, Picoether (10¹²)
1 Ether = 1000000000 Gwei, Shannon, Nanoether, Nano (10⁹)
1 Ether = 1000000 Szabo, Microether, Micro (10⁶)
1 Ether = 1000 Finney, Milliether, Milli (10³)
1 Ether = 0.001 Kether, Grand, Einstein (10^-3)
1 Ether = 0.000001 Mether (10^-6)
1 Ether = 0.000000001 Gether (10^-9)
1 Ether = 0.000000000001 Tether (10^-12)
我們也可以使用以太坊單位換算機來計算https://converter.murkin.me/
私鑰,公鑰與帳號
以太坊地址(address)是綁定到私鑰(private key)。 而私鑰只不過是一個 64 個字的16進制的字串(32 bytes)。 私鑰會產生公鑰,而供鑰則會再產生以太坊的地址。
一般正常狀況,我們是用自己的電腦(一個亂數產生器)自行產生私鑰。 沒有中心化單位分發私鑰(但加密貨幣交易所則是替我們產生私鑰)。 私鑰始終由應由我們自行產生,並自行保管。
公鑰是從私鑰衍生出來的,是公開給大眾的。 以太坊地址源自公鑰。私鑰用於簽署交易。 交易簽名對於每筆交易都是唯一的。公鑰或以太坊地址可用於驗證簽名是否有效。
PoW, PoS與PoA(Proof of Authority)
在PoW環境中,所有挖礦節點都試圖找到一種數學問題的答案。 基本上,就是找到經過hash的輸入值。 他們通過不斷計算哈希值以“暴力”的方式做到這一點。就是不斷去猜測原來的輸入值。而這需要大量的電腦運算能力和電力。
在PoS環境中,一些想要賺取運算手續費的礦工會質押一些以太幣(32個),並等待被隨機選擇來產生一個新區塊。 這種方式消耗的電力很少,挖礦只需要少量的運算能力。
在 PoW 和 PoS 這兩種區塊鏈網路中,基本上任何人都可以成為礦工。 在PoA網路中,一些節點是被預先選擇並成為礦工節點。以太坊協議的上一次更新稱為 Serenity,PoW被PoS和所謂的Beacon-Chain所取代。
EOA(Externally Owned Accounts)與智能合約
在以太坊中,有兩種不同類型的帳號存在。 一種是由私鑰控制。 這就是所謂的EOA:由私鑰控制的帳號。
而當智能合約被部署時,它在區塊鏈會產生自己的地址。 這樣,地址就可以通過代碼進行自我管理,而不受私鑰的控制。
交易(transactions)
如果我們發出一個交易,以下幾種參數必須備包含在交易內容中:
- nonce — 它是用於發送帳號防止"replay攻擊"的序號。我們可以把它看成每次我們在ATM提款時,收據上都會有針對此筆交易的唯一序號。
- gasprice — 提供”每個gas”的價格
- startgas — 針對我們交易做出gas(手續費)的上限值
- to — 我們要把以太幣發出到哪個地址(帳號)
- value — 要轉多少以太幣出去
- data — 要發送甚麼資料
- v, r, s — 從私鑰產生的ECDA簽名
以太坊網路與節點
以太坊協議
以太坊本身只是一個定義通訊方式的協議。 它既不是商用軟件,也沒有專利。 相反,它是開放的,並且有幾種不同的以太坊協議實作。
其中一種實做是“Go-Ethereum”或簡稱“GETH”,用 GO 語言開發,另一個是用 Rust 編寫的“Parity”。而也沒有單一的“以太坊安裝程式”,而是以外部各種不同的方式來驗證協議規範的正確性。
以太坊節點與外部的通訊
以太坊節點使用以太坊協議相互通信。 但是,我們也可以從區塊鏈網路的外部連接到以太坊節點。 而且有多種方法可以連接到以太坊節點。
通常使用通過 HTTP、IPC 和 WebSockets 進行連接。 例如,對於 HTTP,以太坊節點接受 JSON-RPC 格式的資料格式。 這是外部與以太坊節點通訊的標準化方式。
所以,任何使用 JSON-RPC 呼叫的軟體都可以通過以太坊節點連接到區塊鏈內。 換句話說:每個以太坊節點還必須實現一個 JSON-RPC 接口。區塊鏈節點之間的通訊則使用 DevP2P 協議。
區塊鏈網路
在以太坊中,區塊鏈是通過創世紀區塊(第一個區塊)定義的,此後的每個區塊都通過挖礦來產生。 每次開採一個新區塊時,它都包含前一個區塊簽名的一部分以及通過礦工開採的交易。
在以太坊中,每個人都可以基於以太坊協議啟用自己的區塊鏈。 這意味著可以用某種方式複製以太坊的真實環境(Main-Net稱主網),使其以完全相同的方式作業。這些是與主網非常相似的測試網路,拿來做任何測試,例如從POW過渡到PoS。 哪怎麼辨識主網與其它測試網路,以下則有一個清單可以參考,https://ethereum.stackexchange.com/questions/17051/how-to-select-a-network-id-or-is-there-a-list-of-network-ids/17101#17101。
區塊鏈中的節點作業
以太坊協議有多個冗餘實作,以保證實作的正確性。 此外,並非所有區塊鏈節點都以相同的方式作業。 有些純粹是為了開發和在記憶體中(In-memory)保存區塊鏈,而這只是模擬環境。
真實的以太坊主網的節點協議
1. Cpp-ethereum
2. Go-Etheruem (GETH)
3. Parity
4. Pantheon
In-Memory的模擬環境則有:
1. Ganache
2. Truffle Developer Console
礦工的功能
礦工在挖礦過程中有一些功能可以讓區塊鏈是“活著”的。 假設礦工找到了下一個區塊,那麼接下來可能會發生:
- 它將區塊鏈中監聽交易並在區塊中盡可能納入的未決交易
- 會判斷並包含叔叔區塊(uncles block)
- 更新coinbase/etherbase的account-balance,也就是挖礦獎勵的錢包結餘
這裡要解釋一下甚麼是叔叔區塊。所謂uncles block是沒有在正式鏈上得到認可的區塊。 因為只有一個區塊可以被挖掘並被認可為區塊鏈上的正式鏈。 剩下的區塊就是叔叔區塊。 當兩個或更多礦工幾乎同時生產區塊時,就會產生叔叔區塊。
叔叔區塊類似於比特幣上的孤兒區塊,但與以太坊協議有一點點不同。 叔叔區塊是以太坊網路不認可的有效區塊。 礦工會因產生叔叔區塊而獲得報酬,這與孤兒區塊不同,礦工在孤兒區塊中得不到獎勵。
以太坊的基本開發介紹
以太坊的代碼是需要經過編譯,之後會產生bytecode實際運行在區塊鏈中。以下有幾種開發語言可以進行智能合約的開發。
- Solidity — 目前最多人使用的
- Vyper — V神所推出的,強調安全性
- LLL — 低階的LSIP語言
- Bamboo
- Move — 由Meta所開發,強調易學並有很強的安全性
從技術角度來看,智能合約到底是甚麼?
智能合約是一個類似於bytecode execution的狀態機。 它在以太坊區塊鏈上運作,並由網路中的所有節點執行。 它通常使用高階語言編寫,並將代碼編譯成assembly和bytecode。
EVM Assembly與Opcodes
EVM會知道EVM assembly中的多個opcodes,這些opcodes由bytecode呈現。 這些opcodes是 EVM 的讀取、寫入、跳轉、存儲等的指令。它可以從stack或storage讀取/寫入。詳細的opcodes可以參閱此篇連結。
Gas與Gas需求
以太坊區塊鏈上的每個動作(actions)都是通過遵循bytecode或opcodes來完成的。 每個opcode 都會消耗一定數量的gas。
不同操作的gas需求我們可以參閱Ethereum Yellow paper的第27頁。如下圖所示。
另外,目前可用的最大 gas 量上限為 670 萬。 換句話說,執行智能合約的手續費用會有最大gas量的限制。因為使用者可以提供跟礦工可以接受的gas量都不可以大於這個數字。
這導致“正常”軟體開發人員面臨多個問題。
- 在開發階段我們測試的使用者可能只有幾個人,但是當我們部署主網環境中時,使用者可以一直會呈指數型成長,而gas的使用數量可能就超過我們的預期
- Loops是一個大陷阱。如果真的要用到Loops我們應該要確切的知道整個智能合約運作時會消耗多少gas量
區塊鏈中的gas成本經濟
Gas 是根據交易執行的複雜程度來計算的。 我們是用“gas”而不是“以太幣”支付的原因是: 因為以太幣的價值是不斷變動的,而gas的單位費用必須固定,整個交易運作的成本才不會受到以太幣變動的影響。
讓我們舉一個例子:
使用到合約交易需要 500 萬 gas 才能執行。 我們為每個gas提供 3GWei,即 (3*10^-9 Ether) * (5* 10⁶) => 3*5*10^-3 = 0.015 Ether。
在以太坊網路忙碌期間,gas價格可能會上漲。 礦工決定將哪些交易納入到下一個區塊中,並且一個區塊中的交易量是有限的。 因此,交易通過 gas 和 gas 價格競爭,基本上是礦工獲得的獎勵。也就是說礦工會優先選擇出價多的gas優先處理。
甚麼是PATRICIA MERKLE TREES
Patricia Merkle Trie 提供了一種經過密碼驗證的key-value資料結構與儲存,Patricia Merkle Tries 是完全確定性的,這意味著具有相同(key-value)綁定的 trie保證是相同的,並延續到最後一個byte。 這意味著它們具有相同的root hash,為資料的insert、lookups和delete提供了 O(log(n)) 效率的作法。 此外,與更複雜的基於比較的替代方案(如read-black trees)相比,它們更易於理解和編碼。
以太坊中的所有key-value組合都存儲在 Merkle Patricia Trie 中,該這個Trie 包含State Trie、Storage Trie、Transaction Trie 和 Receipts Trie。
以太坊區塊鏈中的隱私性
以太坊區塊鏈上最大的問題之一是隱私問題。 現在有 zk-SNARKs,但最大的迷思之一是 Solidity 中的private variables是“private”的這件事。 一般來說,所有資料都是公開的。 將變量定義為“private”只是意味著無法以其他外部的智能合約來使用這個private變量。 為了確保區塊鏈上的隱私,必須創建一個附加層來加密資訊。
Solidity基本介紹
Solidity檔案的佈局(Layout)
solidity是以sol為副檔名,一個solidity檔案中可以包含多個智能合約。一開頭是pragma xxx,這個意思是該solidity檔案要用甚麼版本號的編譯器來編譯,例如以下範例。
pragma solidity >=0.5.0 <0.8.17;
contract JasonContract {}
而代碼內的註解號可以用以下符號
// 一行的註解
/// 一行的註解
/*
*一整段的註解
*/
/**
* @author Jason Kao
* Natspec style
*/
而若要從一個solidity檔案import其他的檔案,可以參閱此篇文件的說明。
Value-Types的要點
大多數value type不是通過reference複制的。 而是有著所有典型的primitive value type,例如整數(integers)、無符號整數(unsinged integers)、布林值(Bolleans)。
- 沒有小數點
- 整數會被截斷,所以它們會無條件捨去小數點之後的數字
另外還有以太坊中的地址(addresses也可以看做錢包),地址跟以下的資訊會有關聯:
- 地址會有餘額(地址會跟錢掛勾)
- 地址還有以下幾種交互方式:
- Address.transfer(ether_amount) 將以太幣發送到“地址”。 如果發生異常,則將其級聯(cascaded)。2300 gas為其上限。
- Address.send(ether_amount) 將以太幣發送到“地址”。 如果發生異常,則返回布林值“false”。 2300 gas為其上限。
- Address.call.value()() 讓我們進行交互並將以太幣發送到“地址”。 如果發生異常,則返回一個布林值 (false)。 所有gas都會被轉發。
- Address.delegatecall,它使用當前呼叫合約的範圍,主要用於library。
- Member variable:Address.balance,給出“Address”的餘額wei。
Solidity 0.5.0中的 Payable Addresses
在 solidity 0.5.0 之後有一些重大更新,這些更新是為了避免代價高昂的錯誤操作並使我們的業務邏輯意圖更加清晰而實施的。
例如,如果一個地址是“Payable”,則必須特別指定。 否則“transfer”和“send”則不會有作用。例如下面的例子
function 函數名稱(address 錢包地址) public {
錢包地址.transfer(address(this).balance);
}
要變成
function 函數名稱(address payable 錢包地址) public {
錢包地址.transfer(address(this).balance);
};
對於可能接收以太幣的存儲變量也是如此:
address beneficiary;
要變成
address payable beneficiary;
Solidity中的字串(String)
在Solidity是不能文字與文字之間直接做比較的
string1 == string2
上面的語法是沒作用的,如果需要文字之間的比較要用以下方式:
keccak256(string1) == keccak256(string2)
Arrays, Structs與Mappings
在 Solidity 中存取mapping就像其他開發語言中的arrays一樣:
myMapping[keyVal] = value;
不像arrays,在mapping中,每個可能的key都是已經初始化完成的。這個意思是mapping是沒有大小(size)或長度(length)。如果沒有額外的counter變量,我們無法對mapping進行迭代。
Structs是導入新資料類型的便捷方式。 在 Solidity 中我們最好建立一個Structs,因為它會讓我們之後省很多工。在Structs中,每一個value都有一個已經預設值存在。
函數跟變量的可見性
如果一個變量被宣告為 public,那麼solidity 編譯器會在後台自動產生一個 getter 函數。如果一個變量被宣告為private,這意味著它不能通過另一個智能合約實例化需告這一個智能合約以編程方式來存取。內部函數無法從實例化合約存取。
外部函數必須從實例化的智能合約或通過關鍵字“this.internalFunction”呼叫。private函數只能從發出宣告的合約中呼叫。如果省略了可見性,它將在未來引發警告和錯誤。 它會自動假設(在當下)該函數是public。有時“view”和“pure”函數被認為是函數可見性,因為它們將寫入函數變為read-only函數。
Modifiers函數
Modifiers函數可以被繼承,因此在整個繼承樹(inheritance tree)中都是可用的。
View/Pure函數
View函數可從state被讀取,但不能寫入。而Pure函數既不能從state被讀取與寫入。
Fallback函數
如果收款錢包可以在沒有專用(已經有名稱)函數呼叫的情況下被呼叫(或接收以太幣),則應該定義一個fallback函數。 fallback函數是一個匿名函數,當智能合約的交易不包含或包含一個invalid function identifier,因此沒有其他函數匹配時,則該函數會被呼叫。
如果智能合約能夠通過fallback函數接收以太幣,它消耗的gas不應超過 2300。 基本上,需要能發出一個事件通知。如果沒有fallback函數並且在交易期間沒有其他函數可以匹配,則會觸發異常。
全域物件(Global objects): msg. and tx.
Msg.sender 和 tx.origin 都包含一個地址。 例如,一筆交易是針對合約 A 開始的,該合約正在呼叫另一個合約 B,那麼在合約 B 中:
- Msg.sender 將包含合約 A 的地址
- Tx.origin 將包含發起交易的人的地址
此外, msg.timestamp 包含block timestamp,但並不意味著用於隨機變量。
迴圈(loops)
Solidity 支持所有眾所周知的common loops。 但是,如果我們不知道loops的上限,那麼在 Solidity 中使用loops可能會很危險。智能合約執行相關的操作都是基於在 gas 的數量,每個操作都消耗 gas。 在開發過程中,智能合約中可能幾乎沒有實際的gas資料,並且loops運作良好。 但在主網中,loops可能會消耗所有的gas。所以,盡量避免loops。 只有當我們知道上限時才使用loops。
事件(Events)
事件是 DApp 的鏈下部分對某些情況做出反應的好方法。 事件不能被智能合約消耗,而是在某種“側鏈(side chain)”上的運作。根據風格指南(style guide),Events 應該大寫。 在早期版本的 Solidity 中,我們還應該為事件名稱添加前綴(prefix)以避免與函數名稱混淆。 在較新版本的 Solidity 中,我們必須使用關鍵字“emit”來發出事件,從而避免了這種混淆問題。
正式的風格指南(Style Guide)
此連結為Solidity的開發風格指南。它定義了以下一些重要的事項。
函數的編寫應該按如下順序:
- constructor
- fallback函數(如果有的話)
- external
- public
- internal
- private
字串(string)應該用雙引號而不是單引號。
CapWord 樣式:
- Contract與Library名稱
- Structs
- Events
- Enums
mixedCase樣式:
- Functions
- Modifiers
常數(Constants)應該是全大寫並容許底線符號(SOME_CONSTANT)。將每一行代碼根據PEP 8的建議下保持在最多 79(或 99)個characters有助於我們容易的解析代碼。
address.transfer(), address.send(), address.call.value()(), address.delegatecall(), address.staticcall()等函數不同之處。
這些函數的功用看起來很像似,但很容易讓初學者搞混。讓我們在這裡快速的看一下它們有哪些不同。
address.transfer() 和 address.send() 都可用於將資金從 ContractA 發送到“地址”。它們都被認為可以防止re-entrancy,因為它們僅發送 2300 gas的上限。因此,如果有一個合約在“地址”上運作,它就不能做太多事情,因為它沒有足夠的 gas 來運行任何有意義的邏輯,除了發出event。
“address.transfer()”是一個高階函數。它比“address.send()”函數相比是最新的。這意味著使用“address.transfer()”有一個主要優點— — 它會cascade expection,而使用“address.send()”,如果在轉帳過程中發生異常,它只會返回 false。簡而言之:address.transfer() 將與expection綁在一起,而使用 address.send() 我們必須檢查返回值是否為false或true。
有時接收合約(receiving contract)只有 2300 個 gas 是不夠的。這時就要 address.call.value()() 。使用 address.call.value()() 我們可以將所有 gas 轉發(forward)給接收合約,這樣它就可以做更多的操作,而沒有2300 gas上限。但這意味著它既危險又有用。危險是因為如果我們未正確編碼哪麼re-retrancy攻擊就會發生,而這樣我們就需要遵循Checks-Effects-Interactions pattern。
Address.delegatecall() 的作業方式與 address.call 幾乎相同,主要區別在於它將保持呼叫合約(calling contract)中的範圍。 它用於Library。 因此,在library中,我們可以執行“this.myVariable”並實際與呼叫合約的變量交互。
Address.staticcall() 用於非寫入(non-writing)函數。 它本質上是內部使用的“view”和“pure”函數。
Address.call.value()()、address.delegatecall() 和 address.send() 被認為是低階函數,如果在執行期間發生錯誤,則返回布林值 (true/false)。
從 Solidity 0.5.0 開始,低階函數 .call()、.delegatecall() 和 .staticcall() 也返回被呼叫函數的return data。
ERC與EIP
甚麼是ERC(Ethereum Request for Comments)?
它基本上是一個 GitHub issue tracker,開發人員可以在其中詢問對智能合約(和其他)提案的評論。 所以我們常聽到的ERC 20或 721,代表是第20個與721個議題號碼。
甚麼是EIP(Ethereum Improvement Proposal)?
它具有與 ERC 相同的格式,但用於提議對以太坊協議進行更改。
甚麼是ERC20代幣合約?
ERC20 代幣合約是一個“標準”模板,用於在以太坊區塊鏈上部署可替代代幣(fungible tokens)作為智能合約。 它基本上是建立和操作 ERC20 代幣所需功能的標準接口和功能實現。
接口以下列方式定義,取自以太坊 Wiki:
以上這些函數都是 ERC20 標準必須有的函數功能。錢包和其他軟體使用此接口直接與任何相容於 ERC20 的代幣合約進行交互,而不再需要 ABI array。
甚麼是ERC721代幣合約?
ERC721 來自 Ethereum Request for Comments #721。 它是非同質化代幣(non-fungible tokens)的標準,與 ERC20 代幣合約非常相似。 主要區別在於每個代幣都有一個不可互換的數字(non-interchangeable number)。
它因為加密貓合約而出名。 它通常用於收藏品具有特定類型(例如 ERC721 代幣名稱)的收藏品,但每個代幣都有不同的價值。
ERC721 代幣合約的標準接口如下,直接取自 http://erc721.org 網站:
Solidity進階功能
Solidity的繼承(Inheritance)功能
Solidity 支援從多個合約繼承。 繼承使用關鍵字“is”。 因此,“某個智能合約是 另一個智能合約”,意思是某個智能合約擴展成了另一個智能合約。以下為一個範例:
contract MyContract{
function jasonone() {
}
}
contract MyOtherContract is MyContract {
function jasontwo() {
}
}
我們可以實例化 MyOtherContract 並在其中使用“jasonone”這函數(如下範例):
MyOtherContract myOtherContract = new MyOtherContract();
myOtherContract.jasonone();
這適用於屬性和函數。然而,函數上的關鍵字“private”會使該函數在整個繼承圖中是無法使用的。在上述的描述中,多重繼承是可能的(如下範例):
SomeContract is ContractA, ContractB, ContractC {}
更多的繼承說明請參閱solidity文件庫。
甚麼是Web3.js?
Web3.js 經常與“Web 3.0”混淆。 Web3.js 是用來與區塊鏈節點交互的 JavaScript library,可以與區塊鏈節點的 JSON RPC 端口進行交互。它對輸出資料進行所有的原始編碼、格式化、填充和解碼。而這些動作不用我們再自行去進行開發跟區塊鏈溝通的工具。
甚麼是MetaData與ABI Array?
當我們編寫一個智能合約並將其部署在區塊鏈上時,沒有人真正知道如何與該合約進行交互。 從外部看,我們只能看到bytecode,無法猜測必須如何填充輸入參數,或者必須如何解碼返回值。 有哪些功能? 還有它們的名稱是甚麼?
以下是一個簡單的例子:
pragma solidity ^0.4.23;
contract JasonContract {
uint JasonStorageVar;
function JasonFunction(uint someArg) public {
JasonStorageVar = someArg;
}
}
ABI(Application Binary Interface),是智能合約的JSON格式的input/output和函數呈現。這樣誰都知道與智能合約交互應該用甚麼樣的格式。一個簡單的範例如下:
[
{
"constant": false,
"inputs": [
{
"name": "someArg",
"type": "uint256"
}
],
"name": "myFunction",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
}
]
而MetaData 是一個更大的array,包含各種資訊。 從編譯器版本到最終部署時的合約地址(一個範例如下)。
address.send與address.transfer的不同處
這兩個函數很容易會讓初學者搞混。這兩個功能都可用於將資金從智能合約轉移到某一個錢包地址。 address.transfer會有cascading expections的高階函數,address.send則是低階函數,出錯時只會返回布林值。
這個布林值的問題在於它導致了開發上的問題,開發人員經常會忘記檢查返回值並將資金鎖在智能合約中。
因此,盡可能使用“Address.transfer()”,“Address.send()”在轉帳失敗、gas 用完、呼叫合約拋出異常等等,它只會返回“false”。但這兩個函數最多只會用到2300個gas 。
Solidity的異常狀況
Solidity 發生的異常狀況會讓我們無法在智能合約中擷取到它們。 它們會結束當前交易的執行並rollback到目前為止在這個正在運行的交易中所做的一切。
發生錯誤時會觸發異常,例如用盡gas。 但它們也可以手動觸發。
觸發異常的關鍵字有“require”、“assert”和“revert”三個關鍵字。 在 Solidity 0.5.0 中不能再使用另一個已棄用的關鍵字“throw”。更多的異常說明可以參閱此文件。
例如我們可以要求智能合約變量要小於或等於50:
require(myVar <= 50, 'My Var is smaller than 50');
哪require 與 assert有甚麼不同呢?
Require是使用來檢查用戶輸入的參數。它會返還所有剩餘的gas。
Assert用於檢查不應該發生的內部狀態(internal states)。 它用於檢查invariants。 它會消耗所有剩餘的氣體。
為何要避免 .call .value()()函數?
通常在與其他智能合約交互和轉帳時,只發送 2300 個 gas 是不夠的。使用 address.call.value()() 讓我們可以與其他智能合約進行交互超出2300個gas量。
但這有潛在的危險,如果我們合約的呼叫不遵循checks-effects-interactions pattern,它可能會導致reentrancy attacks,因為被呼叫的合約可以進行call back。更多的reentrancy attack請參閱此文件。
此外,它是一個低階函數,不會發出錯誤。 因此,如果被呼叫的合約拋出異常,它只會返回一個布林值“false”。
只發送特定數量的gas並明確呼叫外部合約時的含義的一種方法是使用named calls。更多的外部呼叫資訊請參閱此文件。
在這種情況下,我們可能會做這樣的事:
MyContractInstance myInstance = MyContractInstance(addressOfMyContract);
myInstance.contractFunction.value(1000).gas(50000);
這樣做的問題是它也會使用 call.value()() 這個低階函數,但會有cascades expections。無論我們要做什麼,都要特別小心外部合約的呼叫。
Solidity的低階Assembly
有時候,在高階的 Solidity 中,不可能以我們想要的方式編寫我們想要的代碼。 有時我們需要更細緻的存取。
在 Solidity 中,您可以線上加入 Assembly,從而對程序流程進行更多控制。 它指定了一種更易於使用的彙編語言,尤其是在EVM中的特殊情況下。 一個例子是找到正確的stack slot,使用 Solidity 中的inline-assembly “overlay”更容易。 更多的資訊請參閱in-line assembly文件。
Truffle開發環境
甚麼是truffle?
Truffle 是一個用於 DApp 開發的框架。 不要與 truffle-contract 混淆,truffle-contract 是智能合約交互的librasry和abstraction,類似於 Web3.js。
Truffle 可以成為具有unit testing和CI(continuous integration)的分佈式應用程序開發的基礎。更多Truffle資訊請參閱官網https://trufflesuite.com/。
但以下三件事是要強調的重點:
- Built-In Smart Contract Compilation,Linking, Deployment and Binary Management.
以太坊中有多個compiler implementations用在Solidity。 如果我們有多個需要串接在一起的合約,在一個在開發路徑上處理它們的團隊,那麼部署和編譯的固定路徑就是我們真正需要的。 - Automated Contract Testing for Rapid Development。
鏈上的智能合約是不可變的,所以我們需要做的一件事就是測試、測試和再測試。 Truffle 使包含開發鏈在內的內建測試框架的unit testing變得容易。我們可以用JavaScript 和 Solidity 編寫測試。 - Network Management for Deployment
對於多個區塊鍊和多個開發人員,有一種簡單的方式部署到多個網路或區塊鏈總是好的。 使用 truffle,我們可以在單一個 JavaScript 配置檔案中管理所有區塊鏈。
Truffle作業方式
Truffle 是一個基於 NodeJS 的 JavaScript library。因此,要安裝和運行 truffle,我們需要先安裝 Node 和 Node Package Manager (NPM)。 我們可以在此網站上找到適用於所有平台的 Nodehttps://nodejs.org。truffle 和 npm 沒有任何自動更新機制,因此我們可能需要不時手動更新 truffle。 這通常意味著卸載並重新安裝 truffle。
安裝完成後大概會如下圖這樣
如我們看到,安裝的版本是“4.1.11”。 有時,當事情沒有按預期時,rollback到不同的版本是個好主意。 這可以通過在安裝期間指定版本號來完成:
npm install -g trufflw@版本號碼
之後我們需要初始化一個新專案
truffle init
在一個空的資料夾中,然後 truffle 將從 GitHub 下載一個模板專案並解壓縮它。這個專案將使用預設結構初始化,這些是每個 truffle 專案中的重要資料夾(長得大概如下圖):
在“contracts”資料夾中是智能合約所在的位置。 這些是 Solidity 檔案。
在“migrations”資料夾中是部署“合約”資料夾中智能合約的規則和腳本。 合約不會自動部署,我們必須為每個solidity 檔案的部署編寫規則。
“test”資料夾中同時包含 JavaScript 和 Solidity 測試檔案。然後有兩個 js 檔案,“truffle.js”和“truffle-config.js”。 兩者是相同的,Windows 上有時需要“truffle-config.js”文件,因為它與可執行文件“truffle”有命名衝突。
Truffle Box
truffle 框架中一個值得一提的項目是“Truffle Box”。 這些是預配置的“mini scaffolding projects”,可以輕而易舉地啟動一個新的分佈式應用程序。更多的truffle box清單可以查閱此連結:https://trufflesuite.com/boxes/。
如果我們是從原生 JavaScript 或 Vue、React、Angular 開始,我們可以從truffle box開始。要獲取truffle box,例如 webpack box,只需輸入
truffle unbox webpack
上面的指令類似於複製github repo內的代碼並執行”npm init“指令。
在Truffle中編寫unit tests
用 truffle 編寫測試非常容易,每個智能合約乃至於每一個合約中的函數都應該被測試。
Truffle 同時支援 JavaScript tests和 Solidity tests。JavaScript tests可以測試與“來自外部世界”的智能合約的交互,而 Solidity tests將智能合約測試為與其他智能合約的交互。
假設我們如上所述從truffle中拆解了 webpack box。 讓我們先看看提供的 JavaScript 範例測試。 我們來一行行的拆解。
從tests/metacoin.js的檔案開始
var MetaCoin = artifacts.require("./MetaCoin.sol");
contract('MetaCoin', function(accounts) {
it("should put 10000 MetaCoin in the first account", function() {
return MetaCoin.deployed().then(function(instance) {
return instance.getBalance.call(accounts[0]);
}).then(function(balance) {
assert.equal(balance.valueOf(), 10000, "10000 wasn't in the first account");
});
});
it("should call a function that depends on a linked library", function() {
var meta;
var metaCoinBalance;
var metaCoinEthBalance;
return MetaCoin.deployed().then(function(instance) {
meta = instance;
return meta.getBalance.call(accounts[0]);
}).then(function(outCoinBalance) {
metaCoinBalance = outCoinBalance.toNumber();
return meta.getBalanceInEth.call(accounts[0]);
}).then(function(outCoinBalanceEth) {
metaCoinEthBalance = outCoinBalanceEth.toNumber();
}).then(function() {
assert.equal(metaCoinEthBalance, 2 * metaCoinBalance, "Library function returned unexpeced function, linkage may be broken");
});
});
it("should send coin correctly", function() {
var meta;
// Get initial balances of first and second account.
var account_one = accounts[0];
var account_two = accounts[1];
var account_one_starting_balance;
var account_two_starting_balance;
var account_one_ending_balance;
var account_two_ending_balance;
var amount = 10;
return MetaCoin.deployed().then(function(instance) {
meta = instance;
return meta.getBalance.call(account_one);
}).then(function(balance) {
account_one_starting_balance = balance.toNumber();
return meta.getBalance.call(account_two);
}).then(function(balance) {
account_two_starting_balance = balance.toNumber();
return meta.sendCoin(account_two, amount, {from: account_one});
}).then(function() {
return meta.getBalance.call(account_one);
}).then(function(balance) {
account_one_ending_balance = balance.toNumber();
return meta.getBalance.call(account_two);
}).then(function(balance) {
account_two_ending_balance = balance.toNumber();
assert.equal(account_one_ending_balance, account_one_starting_balance - amount, "Amount wasn't correctly taken from the sender");
assert.equal(account_two_ending_balance, account_two_starting_balance + amount, "Amount wasn't correctly sent to the receiver");
});
});
});
測試本身使用 Mocha 框架和 Chai assertions。使用 Node-typical imports,我們可以從智能合約中導入工件(artifacts)。 工件與 ABI Array 基本相同,包含更多Meta-data,讓我們可以通過 truffle 與智能合約進行交互。
之後,我們使用關鍵字“contract(‘ContractName, function …”描述合約,它接收一個函數及其連接的區塊鏈節點的帳號。這樣我們就可以存取賬戶並開始測試。測試框架會自動一個接一個地運行測試,但不一定保持順序。記住這一點很重要,所以不要依賴函數的順序。
Truffle 永遠都會刷新部署智能合約,以避免出現“unknow initial states”。所以,我們會從一個新的狀態開始,並在那裡初始化一切!
我們在測試中呼叫的函數通常高度依賴 JavaScript Promises。 Promises是一種處理"併發(concurrency)"的方法。基本上,我們必須等待交易被挖掘,並且有承諾你可以等待,當它被解決時,它將在“.then(…)”區塊中一個接一個繼續運作。
以下我們來看一個Solidity測試。
pragma solidity ^0.4.2;
import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../contracts/MetaCoin.sol";
contract TestMetacoin {
function testInitialBalanceUsingDeployedContract() {
MetaCoin meta = MetaCoin(DeployedAddresses.MetaCoin());
uint expected = 10000;
Assert.equal(meta.getBalance(tx.origin), expected, "Owner should have 10000 MetaCoin initially");
}
function testInitialBalanceWithNewMetaCoin() {
MetaCoin meta = new MetaCoin();
uint expected = 10000;
Assert.equal(meta.getBalance(tx.origin), expected, "Owner should have 10000 MetaCoin initially");
}
}
上面是一個普通的 Solidity 檔案,以 pragma 開頭並包含智能合約。 不同之處在於:它 import“Assert.sol”和“DeployedAddresss.sol”,這是與 MetaCoin 智能合約交互所必需的。
然後將所有測試包裝在“testInitialBalanceWithNewMetaCoin(…)”函數中。 他們還可以使用“Assert.equal(…)”函數進行assertions。
重要的區別在於觀點。 JavaScript tests是從區塊鏈“外部”測試智能合約,而 Solidity tests是從區塊鏈“內部”測試智能合約,將智能合約作為“external source”進行測試。
測試本身可以用 "truffle test"命令行。
同樣,必須盡可能好地對代碼進行unit test。 測試所有的東西,但不要純粹依賴測試。 管理具有風險的資金並最終為我們的智能合約提供“暫停(pause)”選項,讓我們有時間弄清楚發生了什麼以及如何解決它!
智能合約的安全性
安全的最佳實踐
在使用 Solidity 和區塊鏈進行開發時,與傳統方法相比,很多事情都發生了變化。 但是,不僅工作流程發生了變化,我們還面對一門尚未成熟的全新開發語言, 以及定期變動的工具。
首先,始終追隨最新的最佳實踐。 現在重要的事情明天可能會成為舊聞了。 事情瞬息萬變,建議是通過閱讀區塊鍊相關新聞和參與社群來了解最新情況。
除此之外,對於 Solidity,我們還需要遵循一些事情。
- Check-Effects-Interactions Pattern:
如果我們要呼叫外部智能合約,請始終確保不要在呼叫"之後"修改變量(variables),請在呼叫"之前"進行修改。 檢查返回值並盡量減少使用低階函數以避免Reentrancy情況。 - Privacy: “Private”變量在區塊鏈中不是private的。
- Loops & Gas Limits: 盡可能避免Loops。 如果我們無法避免Loops,請避免使用“開放式(Open end)”loops。 只有在我們知道迭代次數的情況下使用loops,並且不要低估我們用完 gas 的速度。
- 不要只處理智能合約餘額。 我們是可以強制將以太幣發送到智能合約,所以不要期望我們的智能合約會紀錄我們使用payable函數來統計以太幣數量。
- 使用“withdraw”或“pull”模式而不是“send”或“push”:通過withdraw,我們有一個專門的函數讓用戶“提”款。 取款是一種“pull”方式,而匯款是一種“push”方式。
更多的最佳實踐與建議,請參閱此篇文件。
為何要進行單元測試(Unit Testing)
單元測試不僅可以幫我們找到回歸錯誤(regression bugs)。通常,在進行單元測試時,我們能更深入地了解我們的代碼以及它可能呈現的所有不同狀態。反過來,這可以讓我們及早發現bugs,並且通常還可以揭示設計缺陷。
單元測試不必很複雜。使用像 truffle 這樣的框架,它可以非常簡單直接。
如果我們運做智能合約,那麼通常會涉及金錢。如果您的智能合約中存在漏洞,那麼駭客竊取我們的錢通常只是時間問題。
將錢送給駭客並不是唯一可能錢會不見的可能性。如果我們的智能合約存在bugs,例如,gas 消耗過多或函數無法正常工作,則我們的用戶可能會花費比實際需要更多的費用來運行智能合約。
智能合約開發流程
一般傳統軟體開發流程大概如下:
- 原型製作階段
- Alpha/Beta版本
- 生產版本
- 然後不斷更新,直到產品能滿足市場為止
隨著區塊鏈的發展,我們不能真正做到第 4 步。所以,事情發生了一些變化,但真正的標準還沒有完全出現。
目前的流程是:
- 原型/架構評估階段
- 在框架中實現 + 測試(在In-memory Blockchain)
- 私有鏈測試(In-House測試)
- 公共測試網(沒有真的以太幣)
- 公共主網
對於第一步,通常只是有一個大概的想法,但不確定產品是否可以在區塊鏈上開發。必須考慮交易速度和gas限制等制約。
另外,需要注意的是,我們只將那些真正受益和需要區塊鏈技術的部分轉移到區塊鏈上,而不是甚麼都搬上區塊鏈。在這個階段,讓事情簡單化已經很重要了。智能合約越簡單,它們就越不容易出錯。
在知道產品適合在區塊鏈運作後,通常會在第二步中與團隊在框架中重新集成。這可以包括測試驅動開發 (TDD),但至少要對代碼的各個方面進行單元測試。使用 Ganache 或 Truffle Developer Blockchain 等in-memory區塊鏈可以完成單元測試。挖礦是即時發生的,我們不必等待任何結果。但這也有副作用。在像 Ganache 這樣的in-memory區塊鏈上,我們可以為交易設置一個計時器,但它永遠不會與處理真正的區塊鏈相同。
因此第三步非常重要。在部署和邀請 Beta版本 測試人員之前,將我們的智能合約部署到專用網路。這可以通過 Go-Ethereum 來完成,例如通過創建一個新的私有鏈並套用與測試網或主網環境中相同的制約因素。
如果我們的開發程序有效,那麼就可以邀請真正的用戶了。在投入生產之前,以太坊測試網路之一通常會有一個漫長的測試階段。通過這種方式,真實用戶可以與智能合約進行交互,例如,我們可以找到Scaling bugs。或者改進智能合約。這樣也可以找到適合用戶的升級機制。升級可以是非正式的,比如告訴用戶使用新的合約地址。或者它可以通過代理合約(Proxy-contracts)或其他機制來完成。
最後一步是在以太坊主網絡上部署。這是交易和互動需要花費真金白銀的地方。因此,用戶必須能夠期待沒有錯誤的智能合約。
智能合約的升級與錯誤(bugs)以及整個生態系
以太坊區塊鏈在不斷變化。共識(Consensus)只是意味著所有節點都同意某件事。但是這個“某件事”是可以改變的。例如,gas limit。如果加密經濟學要求,它可以增加或降低。但這不是套用update的唯一主題。
隨著 Casper/Serenity 發生的變化以及向PoS的轉變,以太坊區塊鏈發生了根本性的變化。在這樣的改變之後,之前有效的可能無效。這意味著,由於底層系統發生了變化,工作中的智能合約可能會突然變得不同。
可以儘早考量的一個選項是“暫停”功能,以防平台意外升級。這樣可以爭取到最終解決方案可用的一些時間。這導致了一個有效的升級計劃。在原型設計/架構規劃階段就可以考慮升級計劃。雖然沒有萬能的升級機制,但可以通過“非傳統”方式升級智能合約。這可以通過要求用戶手動移動他們的資料,甚至可以使用在不同地址使用一組合約的代理合約。這是保持不變性和信任與能夠對bugs做出反應之間的平衡行為。
與未知的來源交互
在某些時候,我們可能會與未知來源進行交互。這並不意味著使用非開源的library。舉一個簡單的withdraw method,其中一個人將資金提取到特定地址。
如果我們使用 .call.value()() 方法將資金發送到一個地址,那麼我們本身就處於風險之中,因為被呼叫的地址接收了所有的 gas。如果地址只是一個 EOA 那麼就沒有問題。但是如果地址上有一個智能合約試圖重新呼叫我們的智能合約並重新提取資金而我們沒有針對這種情況進行處理那麼我們可能會失去了所有的資金。
因此,如果可能,我們應該避免在進行外部呼叫後對我們自己的合約狀態進行任何變更。
此外,在我們的合約代碼中標記不受信任的來源,以便它們能被特別注意到。並留意 address.transfer()、address.send() 和 address.call.value()() 之間的區別。
永遠使用 pull方式取款而非 push ,因為外部呼叫可能會意外失敗或故意讓我們的智能合約處於未知狀態。
強制將資金送進智能合約
您可能會發現自己遇到的一種情況是根據存儲存入和取出資金的counter-variable斷定智能合約內的餘額。
假設我們有一個智能合約,它以 0 wei 開頭。有人存入 10 個以太幣,因此您為用戶增加一個帶有餘額的變量,並斷定所有存入的以太幣等於合約的餘額。我們在那個智能合約中沒有fallback函數,所以你假設沒有人可以在實際的“deposit”函數之外存入以太幣。
這個假設大錯特錯。
在以太坊區塊鏈上,有可能以兩種方式存放以太幣,從而危及這些判斷。
首先,可以在部署合約之前將資金發送到合約地址。合約地址可以預先計算。它是創建智能合約的地址的哈希值加上該地址的隨機數。如果知道將使用哪個地址來部署智能合約,則可以在部署合約之前向未來的合約地址發送少量 wei。這樣,合約就已經以非零餘額開始了。
另一種選擇是將資金發送到智能合約,即使它沒有fallback函數或自動拋出異常。如果資金存儲在稱為 ContractA 的智能合約中,並且想要將這些資金強行發送到 ContractB,則可以使用“selfdestruct”函數。在 ContratA 中使用“selfdestruct(AddressOfContractB)”,ContractB 將自動收到 ContractA 中的所有存儲資金。這是無法避免的。
所以,無論我們做什麼,永遠不要編寫一個只檢查合約餘額的不變量(invariant)。不要假設我們檢查合約餘額的“assert”語法總是與所有記錄的 wei-deposits 相同。
區塊鏈中的隨機數
有兩個全局變量被錯誤地用於區塊鏈上的隨機性。
一個是timestamp,存儲在“block.timestamp”或“now”中。 這是最常見的錯誤。 這個timestamp雖然在一定程度上是隨機的,但會受到礦工的影響。 timestamp最終由礦工設定,而不是獨立的設定。 因此,如果我們的智能合約很受歡迎,那麼礦工很可能會受到激勵來影響這個timestamp。
另一個變量是 block.number,這也不是一個好主意。 這個變量本身並沒有太多用於隨機性,它可以用來確定例如拍賣期的結束。以下是一個範例:
使用上面這個範例也不是一個好主意,因為不能保證區塊挖掘時間不會改變。 特別是在平台升級方面。
隨機性仍然是區塊鏈上積極研究的問題,但有兩種方法慢慢出現。方法一是利用所有參與者作為隨機化的智能合約。 另一種方法是信任外部預言機或將隨機性綁定到比特幣區塊鏈。
OpenZeppelin與library
與其他“歷史悠久”的開發語言(如 Java 或 C++)相比,以太坊沒有太多可用的框架和Library。特別是在這裡,在可能有數千個專案中使用到Library時,審查和審計智能合約非常重要。
OpenZeppelin 為代幣、大眾募資和更多應用程序提供模板。 他們所有的智能合約都經過audit和peer reviewed,可以以簡單的方式採用。不過要提到的一件事是,一旦有人調整了這些合約,他們就會脫離了audit。 儘管如此,從模板開始通常比從頭開始更好。
以太坊區塊鍊中的隱私(Privacy)
一個很大的誤解是private variables是private。我們之前談過這個,但它非常重要,我們需要再澄清一下。
private variables對其他智能合約是private,但對“外部世界”不是。任何人都可以進入區塊瀏覽器並讀取智能合約的當前狀態(包括曾經發送過的所有交易,包括交易資料)。
這意味著,區塊鏈上的資料必須被視為公開的。
可以這樣想:這就像使用 http 而不是 https。沒有任何本機加密層。
主要有兩個問題:一個是敏感資料,比如醫療記錄,另一個是合約邏輯中使用的資料,必須暫時保密。
就醫療記錄或其他敏感資料而言,交易必須在發送前進行編碼加密,並在再次讀取資料後進行解碼。以太坊區塊鏈沒有內建的本機加密。