首頁>技術>

作為一門面向區塊鏈平臺設計的圖靈完備的程式語言,Solidity支援函式呼叫、修飾符、過載、事件、繼承等多種特性,在區塊鏈社群中,擁有廣泛的影響力和踴躍的社群支援。但對於剛接觸區塊鏈的人而言,Solidity是一門陌生的語言。

智慧合約編寫階段將從Solidity基礎特性、高階特性、設計模式以及程式設計攻略分別展開,帶讀者認識Solidity並掌握其運用,更好地進行智慧合約開發。

智慧合約程式碼結構

任何程式語言都有其規範的程式碼結構,用於表達在一個程式碼檔案中如何組織和編寫程式碼,Solidity也一樣。

本節,我們將透過一個簡單的合約示例,來了解智慧合約的程式碼結構。

上面這段程式包括了以下功能:

透過建構函式來部署合約

透過setValue函式設定合約狀態

透過getValue函式查詢合約狀態

整個合約主要分為以下幾個構成部分:

狀態變數 - _admin, _state,這些變數會被永久儲存,也可以被函式修改

建構函式 - 用於部署並初始化合約

事件 - SetState, 功能類似日誌,記錄了一個事件的發生

修飾符 - onlyAdmin, 用於給函式加一層"外衣"

函式 - setState, getState,用於讀寫狀態變數

下面將逐一介紹上述構成部分。

狀態變數

狀態變數是合約的骨髓,它記錄了合約的業務資訊。使用者可以透過函式來修改這些狀態變數,這些修改也會被包含到交易中;交易經過區塊鏈網路確認後,修改即為生效。

uint private _state;

狀態變數的宣告方式為:[型別] [訪問修飾符-可選] [欄位名]

建構函式

建構函式用於初始化合約,它允許使用者傳入一些基本的資料,寫入到狀態變數中。

在上述例子中,設定了_admin欄位,作為後面演示其他功能的前提。

和java不同的是,建構函式不支援過載,只能指定一個建構函式。

函式

函式被用來讀寫狀態變數。對變數的修改將會被包含在交易中,經區塊鏈網路確認後才生效。生效後,修改會被永久的儲存在區塊鏈賬本中。

函式簽名定義了函式名、輸入輸出引數、訪問修飾符、自定義修飾符。

function setState(uint value) public onlyAdmin;

函式還可以返回多個返回值:

在本合約中,還有一個配備了view修飾符的函式。這個view表示了該函式不會修改任何狀態變數。

與view類似的還有修飾符pure,其表明該函式是純函式,連狀態變數都不用讀,函式的執行僅僅依賴於引數。

如果在view函式中嘗試修改狀態變數,或者在pure函式中訪問狀態變數,編譯器均會報錯。

事件

事件類似於日誌,會被記錄到區塊鏈中,客戶端可以透過web3訂閱這些事件。

定義事件

event SetState(uint value);

構造事件

emit SetState(value);

這裡有幾點需要注意:

事件的名稱可以任意指定,不一定要和函式名掛鉤,但推薦兩者掛鉤,以便清晰地表達發生的事情.

構造事件時,也可不寫emit,但因為事件和函式無論是名稱還是引數都高度相關,這樣操作很容易筆誤將事件寫成函式呼叫,因此不推薦。

Solidity程式設計風格應採用一定的規範。關於程式設計風格,建議參考

https://learnblockchain.cn/docs/solidity/style-guide.html#id16

修飾符

修飾符是合約中非常重要的一環。它掛在函式宣告上,為函式提供一些額外的功能,例如檢查、清理等工作。

在本例中,修飾符onlyAdmin要求函式呼叫前,需要先檢測函式的呼叫者是否為函式部署時設定的那個管理員(即合約的部署人)。

值得注意的是,定義在修飾符中的下劃線“_”,表示函式的呼叫,指代的是開發者用修飾符修飾的函式。在本例中,表達的是setState函式呼叫的意思。

智慧合約的執行

瞭解了上述的智慧合約示例的結構,就可以直接上手執行,執行合約的方式有多種,大家可以任意採取其中一種:

方法一:可以使用FISCO BCOS控制檯的方式來部署合約,具體請參考

https://fisco-bcos-documentation.readthedocs.io/zh_CN/latest/docs/installation.html#id7

方法二:使用FISCO BCOS開源專案WeBASE提供的線上ide WEBASE-front執行

方法三:透過線上ide remix來進行合約的部署與執行, remix的地址為

http://remix.ethereum.org/

本例中使用remix作為執行示例。

編譯

部署

編譯成功後就可進行部署環節,部署成功後會出現合約例項。

setState

合約部署後,我們來呼叫setState(4)。在執行成功後,會產生一條交易收據,裡面包含了交易的執行資訊。

在這裡,使用者可以看到交易執行狀態(status)、交易執行人(from)、交易輸入輸出(decoded input, decoded output)、交易開銷(execution cost)以及交易日誌(logs)。

在logs中,我們看到SetState事件被丟擲,裡面的引數也記錄了事件傳入的值4。

如果我們換一個賬戶來執行,那麼呼叫會失敗,因為onlyAdmin修飾符會阻止使用者呼叫。

getState

呼叫getState後,可以直接看到所得到的值為4,正好是我們先前setState所傳入的值:

Solidity資料型別

在前文的示例中,我們用到了uint等資料型別。由於Solidity型別設計比較特殊,這裡也會簡單介紹一下Solidity的資料型別。

整型系列

Solidity提供了一組資料型別來表示整數, 包含無符號整數與有符號整數。每類整數還可根據長度細分,具體細分型別如下。

定長bytes系列

Solidity提供了bytes1到bytes32的型別,它們是固定長度的位元組陣列。

使用者可以讀取定長bytes的內容。

並且,可以將整數型別轉換為bytes。

這裡有一個關鍵細節,Solidity採取大端序編碼,高地址存的是整數的小端。例如,b[0]是低地址端,它存整數的高階,所以值為0;取b[31]才是1。

變長bytes

從上文中,讀者可瞭解定長byte陣列。此外,Solidity還提供了一個變長byte陣列:bytes。使用方式類似陣列,後文會有介紹。

string

Solidity提供的string,本質是一串經UTF-8編碼的位元組陣列,它兼容於變長bytes型別。

目前Solidity對string的支援不佳,也沒有字元的概念。使用者可以將string轉成bytes。

要注意的是,當將string轉換成bytes時,資料內容本身不會被複製,如上文中,str和b變數指向的都是同一個字串abc。

address

address表示賬戶地址,它由私鑰間接生成,是一個20位元組的資料。同樣,它也可以被轉換為bytes20。

mapping

mapping表示對映, 是極其重要的資料結構。它與java中的對映存在如下幾點差別:

它無法迭代keys,因為它只儲存鍵的雜湊,而不儲存鍵值,如果想迭代,可以用開源的可迭代雜湊類庫

如果一個key未被儲存在mapping中,一樣可以正常讀取到對應value,只是value是空值(位元組全為0)。所以它也不需要put、get等操作,使用者直接去操作它即可。

陣列

如果陣列是狀態變數,那麼支援push等操作:

陣列也可以以區域性變數的方式使用,但稍有不同:

struct

Solidity允許開發者自定義結構物件。結構體既可以作為狀態變數儲存,也可以在函式中作為區域性變數存在。

https://solidity.readthedocs.io/en/v0.6.3/types.html

全域性變數

示例合約程式碼的建構函式中,包含msg.sender。它屬於全域性變數。在智慧合約中,全域性變數或全域性方法可用於獲取和當前區塊、交易相關的一些基本資訊,如塊高、塊時間、合約呼叫者等。

比較常用的全域性變數是msg變數,表示呼叫上下文,常見的全域性變數有以下幾種:

msg.sender:合約的直接呼叫者。

由於是直接呼叫者,所以當處於 使用者A->合約1->合約2 呼叫鏈下,若在合約2內使用msg.sender,得到的會是合約1的地址。如果想獲取使用者A,可以用tx.origin.

tx.origin:交易的"始作俑者",整個呼叫鏈的起點。

msg.calldata:包含完整的呼叫資訊,包括函式標識、引數等。calldata的前4位元組就是函式標識,與msg.sig相同。

msg.sig:msg.calldata的前4位元組,用於標識函式。

block.number:表示當前所在的區塊高度。

now:表示當前的時間戳。也可以用block.timestamp表示。

這裡只列出了部分常見全域性變數,完整版本請參考:

https://solidity.readthedocs.io/en/v0.4.24/units-and-global-variables.html。

合理控制函式和變數的型別

基於最少知道原則(Least Knowledge Principle)中經典面向物件程式設計原則,一個物件應該對其他物件保持最少的瞭解。優秀的Solidity程式設計實踐也應符合這一原則:每個合約都清晰、合理地定義函式的可見性,暴露最少的資訊給外部,做好對內部函式可見性的管理。

同時,正確地修飾函式和變數的型別,可給合約內部資料提供不同級別的保護,以防止程式中非預期的操作導致資料產生錯誤;還能提升程式碼的可讀性與質量,減少誤解和bug;更有利於優化合約執行的成本,提升鏈上資源的使用效率。

守住函式操作的大門:函式可見性

Solidity有兩種函式呼叫方式:

內部呼叫:又被稱為『訊息呼叫』。常見的有合約內部函式、父合約的函式以及庫函式的呼叫。(例如,假設A合約中存在f函式,則在A合約內部,其他函式呼叫f函式的呼叫方式為f()。)

外部呼叫:又被稱為『EVM呼叫』。一般為跨合約的函式呼叫。在同一合約內部,也可以產生外部呼叫。(例如,假設A合約中存在f函式,則在B合約內可透過使用A.f()呼叫。在A合約內部,可以用this.f()來呼叫。)。

函式可以被指定為 external ,public ,internal 或者 private識別符號來修飾。

基於以上表格,我們可以得出函式的可見性 public > external > internal > private。

另外,如果函式不使用上述型別識別符號,那麼預設情況下函式型別為 public。

綜上所述,我們可以總結一下以上識別符號的不同使用場景:

public,公有函式,系統預設。通常用於修飾可對外暴露的函式,且該函式可能同時被內部呼叫。

external,外部函式,推薦只向外部暴露的函式使用。當函式的某個引數非常大時,如果顯式地將函式標記為external,可以強制將函式儲存的位置設定為calldata,這會節約函式執行時所需儲存或計算資源。

internal,內部函式,推薦所有合約內不對合約外暴露的函式使用,可以避免因許可權暴露被攻擊的風險。

private,私有函式,在極少數嚴格保護合約函式不對合約外部開放且不可被繼承的場景下使用。

不過,需要注意的是,無論用何種識別符號,即使是private,整個函式執行的過程和資料是對所有節點可見,其他節點可以驗證和重放任意的歷史函式。實際上,整個智慧合約所有的資料對區塊鏈的參與節點來說都是透明的。

剛接觸區塊鏈的使用者常會誤解,在區塊鏈上可以透過許可權控制操作來控制和保護上鍊資料的隱私。

這是一種錯誤的觀點。事實上,在區塊鏈業務資料未做特殊加密的前提下,區塊鏈同一賬本內的所有資料經過共識後落盤到所有節點上,鏈上資料是全域性公開且相同的,智慧合約只能控制和保護合約資料的執行許可權。

如何正確地選擇函式修飾符是合約程式設計實踐中的『必修課』,只有掌握此節真諦方可自如地控制合約函式訪問許可權,提升合約安全性。

對外暴露最少的必要資訊:變數的可見性

與函式一樣,對於狀態變數,也需要注意可見性修飾符。狀態變數的修飾符預設是internal,不能設定為external。此外,當狀態變數被修飾為public,編譯器會生成一個與該狀態變數同名的函式。

具體可參考以下示例:

pragma solidity ^0.4.0;

contract TestContract {

uint public year = 2020;

}

contract Caller {

TestContract c = new TestContract();

function f() public {

uint local = c.year();

//expected to be 2020

}

}

這個機制有點像Java語言裡lombok庫所提供的@Getter註解,預設為一個POJO類變數生成get函式,大大簡化了某些合約程式碼的書寫。

同樣,變數的可見性也需要被合理地修飾,不該公開的變數果斷用private修飾,使合約程式碼更符合『最少知道』的設計原則。

精確地將函式分類:函式的型別

函式可以被宣告為pure、view,兩者的作用可見下圖。

那麼,什麼是讀取或修改狀態呢?簡單來說,兩個狀態就是讀取或修改了賬本相關的資料。

在FISCO BCOS中,讀取狀態可能是:

讀取狀態變數。

訪問 block,tx, msg 中任意成員 (除 msg.sig 和 msg.data 之外)。

呼叫任何未標記為 pure 的函式。

使用包含某些操作碼的內聯彙編。

而修改狀態可能是:

修改狀態變數。

產生事件。

建立其它合約。

使用 selfdestruct。

呼叫任何沒有標記為 view 或者 pure 的函式。

使用底層呼叫。

使用包含特定操作碼的內聯彙編。

需要注意的是,在某些版本編譯器中,並沒有對這兩個關鍵字進行強制的語法檢查。

推薦儘可能使用pure和view來宣告函式,例如將沒有讀取或修改任何狀態的庫函式宣告為pure,這樣既提升了程式碼可讀性,也使其更賞心悅目,何樂而不為?

編譯時就確定的值:狀態常量

所謂的狀態常量是指被宣告為constant的狀態變數。

一旦某個狀態變數被宣告為constant,那麼該變數值只能為編譯時確定的值,無法被修改。編譯器一般會在編譯狀態計算出此變數實際值,不會給變數預留儲存空間。所以,constant只支援修飾值型別和字串。

狀態常量一般用於定義含義明確的業務常量值。

面向切片程式設計:函式修飾器(modifier)

Solidity提供了強大的改變函式行為的語法:函式修飾器(modifier)。一旦某個函式加上了修飾器,修飾器內定義的程式碼就可以作為該函式的裝飾被執行,類似其他高階語言中裝飾器的概念。

這樣說起來很抽象,讓我們來看一個具體的例子:

pragma solidity ^0.4.11;

contract owned {

function owned() public { owner = msg.sender; }

address owner;

// 修飾器所修飾的函式體會被插入到特殊符號 _; 的位置。

modifier onlyOwner {

require(msg.sender == owner);

_;

}

// 使用onlyOwner修飾器所修飾,執行changeOwner函式前需要首先執行onlyOwner"_;"前的語句。

function changeOwner(address _owner) public onlyOwner {

owner = _owner;

}

}

如上所示,定義onlyOwner修飾器後,在修飾器內,require語句要求msg.sender必須等於owner。後面的"_;"表示所修飾函式中的程式碼。

所以,程式碼實際執行順序變成了:

執行onlyOwner修飾器的語句,先執行require語句。(執行第9行)

執行changeOwner函式的語句。(執行第15行)

由於changeOwner函式加上了onlyOwner的修飾,故只有當msg.sender是owner才能成功呼叫此函式,否則會報錯回滾。

同時,修飾器還能傳入引數,例如上述的修飾器也可寫成:

modifier onlyOwner(address sender) {

require(sender == owner);

_;

}

function changeOwner(address _owner) public onlyOwner(msg.sender) {

owner = _owner;

}

同一個函式可有多個修飾器,中間以空格間隔,修飾器依次檢查執行。此外,修飾器還可以被繼承和重寫。

由於其所提供的強大功能,修飾器也常被用來實現許可權控制、輸入檢查、日誌記錄等。

比如,我們可以定義一個跟蹤函式執行的修飾器:

event LogStartMethod();

event LogEndMethod();

modifier logMethod {

emit LogStartMethod();

_;

emit LogEndMethod();

}

這樣,任何用logMethod修飾器來修飾的函式都可記錄其函式執行前後的日誌,實現日誌環繞效果。如果你已經習慣了使用Spring框架的AOP,也可以試試用modifier實現一個簡單的AOP功能。

modifier最常見的開啟方式是透過提供函式的校驗器。在實踐中,合約程式碼的一些檢查語句常會被抽象並定義為一個modifier,如上述例子中的onlyOwner就是個最經典的許可權校驗器。這樣一來,連檢查的邏輯也能被快速複用,使用者也不用再為智慧合約裡到處都是引數檢查或其他校驗類程式碼而苦惱。

可以debug的日誌:合約裡的事件(Event)

介紹完函式和變數,我們來聊聊Solidity其中一個較為獨有的高階特性——事件機制。

事件允許我們方便地使用 EVM 的日誌基礎設施,而Solidity的事件有以下作用:

記錄事件定義的引數,儲存到區塊鏈交易的日誌中,提供廉價的儲存。

提供一種回撥機制,在事件執行成功後,由節點向註冊監聽的SDK傳送回撥通知,觸發回撥函式被執行。

提供一個過濾器,支援引數的檢索和過濾。

事件的使用方法非常簡單,兩步即可玩轉。

第一步,使用關鍵字『event』來定義一個事件。建議事件的命名以特定字首開始或以特定字尾結束,這樣更便於和函式區分,在本文中我們將統一以『Log』字首來命名事件。下面,我們用『event』來定義一個函式呼叫跟蹤的事件:

event LogCallTrace(address indexed from, address indexed to, bool result);

事件在合約中可被繼承。當他們被呼叫時,會將引數儲存到交易的日誌中。這些日誌被儲存到區塊鏈中,與地址相關聯。在上述例子中,用indexed標記引數被搜尋,否則,這些引數被儲存到日誌的資料中,無法被搜尋。

第二步,在對應的函式內觸發定義事件。呼叫事件的時候,在事件名前加上『emit』關鍵字:

function f() public {

emit LogCallTrace(msg.sender, this, true);

}

這樣,當函式體被執行的時候,會觸發執行LogCallTrace。

最後,在FISCO BCOS的Java SDK中,合約事件推送功能提供了合約事件的非同步推送機制,客戶端向節點發送註冊請求,在請求中攜帶客戶端關注的合約事件引數,節點根據請求引數對請求區塊範圍的Event Log進行過濾,將結果分次推送給客戶端。更多細節可以參考合約事件推送功能文件。在SDK中,可以根據事件的indexed屬性,根據特定值進行搜尋。

合約事件推送功能文件:

https://fisco-bcos-documentation.readthedocs.io/zh_CN/latest/docs/sdk/java_sdk.html#id14

不過,日誌和事件無法被直接訪問,甚至在建立的合約中也無法被直接訪問。

但好訊息是日誌的定義和宣告非常利於在『事後』進行追溯和匯出。

例如,我們可以在合約的編寫中,定義和埋入足夠的事件,透過WeBASE的資料匯出子系統我們可以將所有日誌匯出到MySQL等資料庫中。這特別適用於生成對賬檔案、生成報表、複雜業務的OLTP查詢等場景。此外,WeBASE提供了一個專用的程式碼生成子系統幫助分析具體的業務合約,自動生成相應的程式碼。

WeBASE的資料匯出子系統:

https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Collect-Bee/index.html

程式碼生成子系統:

https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Codegen-Monkey/index.html

在Solidity中,事件是一個非常有用的機制,如果說智慧合約開發最大的難點是debug,那善用事件機制可以讓你快速制伏Solidity開發。

面向物件之過載

過載是指合約具有多個不同引數的同名函式。對於呼叫者來說,可使用相同函式名來呼叫功能相同,但引數不同的多個函式。在某些場景下,這種操作可使程式碼更清晰、易於理解,相信有一定程式設計經驗的讀者對此一定深有體會。

下面將展示一個典型的過載語法:

pragma solidity ^0.4.25;

contract Test {

function f(uint _in) public pure returns (uint out) {

out = 1;

}

function f(uint _in, bytes32 _key) public pure returns (uint out) {

out = 2;

}

}

需要注意的是,每個合約只有一個建構函式,這也意味著合約的建構函式是不支援過載的。

我們可以想像一個沒有過載的世界,程式設計師一定絞盡腦汁、想方設法給函式起名,大家的頭髮可能又要多掉幾根。

面向物件之繼承

Solidity使用『is』作為繼承關鍵字。因此,以下這段程式碼表示的是,合約B繼承了合約A:

pragma solidity ^0.4.25;

contract A {

}

contract B is A {

}

而繼承的合約B可以訪問被繼承合約A的所有非private函式和狀態變數。

在Solidity中,繼承的底層實現原理為:當一個合約從多個合約繼承時,在區塊鏈上只有一個合約被建立,所有基類合約的程式碼被複制到建立的合約中。

相比於C++或Java等語言的繼承機制,Solidity的繼承機制有點類似於Python,支援多重繼承機制。因此,Solidity中可以使用一個合約來繼承多個合約。

在某些高階語言中,比如Java,出於安全性和可靠性的考慮,只支援單重繼承,透過使用介面機制來實現多重繼承。對於大多數場景而言,單繼承的機制就可以滿足需求了。

多繼承會帶來很多複雜的技術問題,例如所謂的『鑽石繼承』等,建議在實踐中儘可能規避複雜的多繼承。

繼承簡化了人們對抽象合約模型的認識和描述,清晰體現了相關合約間的層次結構關係,並且提供軟體複用功能。這樣,能避免程式碼和資料冗餘,增加程式的重用性。

面向物件之抽象類和介面

根據依賴倒置原則,智慧合約應該儘可能地面向介面程式設計,而不依賴具體實現細節。

Solidity支援抽象合約和介面的機制。

如果一個合約,存在未實現的方法,那麼它就是抽象合約。例如:

pragma solidity ^0.4.25;

contract Vehicle {

//抽象方法

function brand() public returns (bytes32);

}

抽象合約無法被成功編譯,但可以被繼承。

介面使用關鍵字interface,上面的抽象也可以被定義為一個介面。

pragma solidity ^0.4.25;

interface Vehicle {

//抽象方法

function brand() public returns (bytes32);

}

介面類似於抽象合約,但不能實現任何函式,同時,還有進一步的限制:

無法繼承其他合約或介面。

無法定義建構函式。

無法定義變數。

無法定義結構體

無法定義列舉。

在軟體開發中,很多經典原則可以提升軟體的質量,其中最為經典的就是儘可能複用久經考驗、反覆打磨、嚴格測試的高質量程式碼。此外,複用成熟的庫程式碼還可以提升程式碼的可讀性、可維護性,甚至是可擴充套件性。

和所有主流語言一樣,Solidity也提供了庫(Library)的機制。Solidity的庫有以下基本特點:

使用者可以像使用合約一樣使用關鍵詞library來建立合約。

庫既不能繼承也不能被繼承。

庫的internal函式對呼叫者都是可見的。

庫是無狀態的,無法定義狀態變數,但是可以訪問和修改呼叫合約所明確提供的狀態變數。

接下來,我們來看一個簡單的例子,以下是FISCO BCOS社群中一個LibSafeMath的程式碼庫。我們對此進行了精簡,只保留了加法的功能:

pragma solidity ^0.4.25;

library LibSafeMath {

/**

* @dev Adds two numbers, throws on overflow.

*/

function add(uint256 a, uint256 b) internal returns (uint256 c) {

c = a + b;

assert(c >= a);

return c;

}

}

我們只需在合約中import庫的檔案,然後使用L.f()的方式來呼叫函式,(例如LibSafeMath.add(a,b))。

接下來,我們編寫呼叫這個庫的測試合約,合約內容如下:

pragma solidity ^0.4.25;

import "./LibSafeMath.sol";

contract TestAdd {

function testAdd(uint256 a, uint256 b) external returns (uint256 c) {

c = LibSafeMath.add(a,b);

}

}

在FISCO BCOS控制檯中,我們可以測試合約的結果(控制檯的介紹文章詳見FISCO BCOS 控制檯詳解,飛一般的區塊鏈體驗),執行結果如下:

=============================================================================================

Welcome to FISCO BCOS console(1.0.8)!

Type 'help' or 'h' for help. Type 'quit' or 'q' to quit console.

________ ______ ______ ______ ______ _______ ______ ______ ______

| | \/ \ / \ / \ | \ / \ / \ / \

| $$$$$$$$\$$$$$| $$$$$$| $$$$$$| $$$$$$\ | $$$$$$$| $$$$$$| $$$$$$| $$$$$$\

| $$__ | $$ | $$___\$| $$ \$| $$ | $$ | $$__/ $| $$ \$| $$ | $| $$___\$$

| $$ \ | $$ \$$ \| $$ | $$ | $$ | $$ $| $$ | $$ | $$\$$ \

| $$$$$ | $$ _\$$$$$$| $$ __| $$ | $$ | $$$$$$$| $$ __| $$ | $$_\$$$$$$\

| $$ _| $$_| \__| $| $$__/ | $$__/ $$ | $$__/ $| $$__/ | $$__/ $| \__| $$

| $$ | $$ \\$$ $$\$$ $$\$$ $$ | $$ $$\$$ $$\$$ $$\$$ $$

\$$ \$$$$$$ \$$$$$$ \$$$$$$ \$$$$$$ \$$$$$$$ \$$$$$$ \$$$$$$ \$$$$$$

=============================================================================================

[group:1]> deploy TestAdd

contract address: 0xe2af1fd7ecd91eb7e0b16b5c754515b775b25fd2

[group:1]> call TestAdd 0xe2af1fd7ecd91eb7e0b16b5c754515b775b25fd2 testAdd 2000 20

transaction hash: 0x136ce66603aa6e7fd9e4750fcf25302b13171abba8c6b2109e6dd28111777d54

---------------------------------------------------------------------------------------------

Output

function: testAdd(uint256,uint256)

return type: (uint256)

return value: (2020)

---------------------------------------------------------------------------------------------

[group:1]>

透過以上示例,我們可清晰瞭解在Solidity中應如何使用庫。

類似Python,在某些場景下,指令『using A for B;』可用於附加庫函式(從庫 A)到任何型別(B)。這些函式將接收到呼叫它們的物件作為第一個引數(像 Python 的 self 變數)。這個功能使庫的使用更加簡單、直觀。

例如,我們對程式碼進行如下簡單修改:

pragma solidity ^0.4.25;

import "./LibSafeMath.sol";

contract TestAdd {

// 新增using ... for ... 語句,庫 LibSafeMath 中的函式被附加在uint256的型別上

using LibSafeMath for uint256;

function testAdd(uint256 a, uint256 b) external returns (uint256 c) {

//c = LibSafeMath.add(a,b);

c = a.add(b);

//物件a直接被作為add方法的首個引數傳入。

}

}

驗證一下結果依然是正確的。

=============================================================================================

Welcome to FISCO BCOS console(1.0.8)!

Type 'help' or 'h' for help. Type 'quit' or 'q' to quit console.

________ ______ ______ ______ ______ _______ ______ ______ ______

| | \/ \ / \ / \ | \ / \ / \ / \

| $$$$$$$$\$$$$$| $$$$$$| $$$$$$| $$$$$$\ | $$$$$$$| $$$$$$| $$$$$$| $$$$$$\

| $$__ | $$ | $$___\$| $$ \$| $$ | $$ | $$__/ $| $$ \$| $$ | $| $$___\$$

| $$ \ | $$ \$$ \| $$ | $$ | $$ | $$ $| $$ | $$ | $$\$$ \

| $$$$$ | $$ _\$$$$$$| $$ __| $$ | $$ | $$$$$$$| $$ __| $$ | $$_\$$$$$$\

| $$ _| $$_| \__| $| $$__/ | $$__/ $$ | $$__/ $| $$__/ | $$__/ $| \__| $$

| $$ | $$ \\$$ $$\$$ $$\$$ $$ | $$ $$\$$ $$\$$ $$\$$ $$

\$$ \$$$$$$ \$$$$$$ \$$$$$$ \$$$$$$ \$$$$$$$ \$$$$$$ \$$$$$$ \$$$$$$

=============================================================================================

[group:1]> deploy TestAdd

contract address: 0xf82c19709a9057d8e32c19c23e891b29b708c01a

[group:1]> call TestAdd 0xf82c19709a9057d8e32c19c23e891b29b708c01a testAdd 2000 20

transaction hash: 0xcc44a80784404831d8522dde2a8855606924696957503491eb47174c9dbf5793

---------------------------------------------------------------------------------------------

Output

function: testAdd(uint256,uint256)

return type: (uint256)

return value: (2020)

---------------------------------------------------------------------------------------------

[group:1]>

更好地使用Solidity library有助於開發者更好地複用程式碼。除了Solidity社群提供的大量開源、高質量的程式碼庫外,FISCO BCOS社群也計劃推出全新的Solidity程式碼庫,開放給社群使用者,敬請期待。

當然,你也可以自己動手,編寫可複用的程式碼庫元件,並分享到社群。

智慧合約設計模式概述

2019年,IEEE收錄了維也納大學一篇題為《Design Patterns For Smart Contracts In the Ethereum Ecosystem》的論文。這篇論文分析了那些火熱的Solidity開源專案,結合以往的研究成果,整理出了18種設計模式。

這些設計模式涵蓋了安全性、可維護性、生命週期管理、鑑權等多個方面。

接下來,本文將從這18種設計模式中選擇最為通用常見的進行介紹,這些設計模式在實際開發經歷中得到了大量檢驗。

安全性(Security)

智慧合約編寫,首要考慮的就是安全性問題。

在區塊鏈世界中,惡意程式碼數不勝數。如果你的合約包含了跨合約呼叫,就要特別當心,要確認外部呼叫是否可信,尤其當其邏輯不為你所掌控的時候。

如果缺乏防人之心,那些“居心叵測”的外部程式碼就可能將你的合約破壞殆盡。比如,外部呼叫可透過惡意回撥,使程式碼被反覆執行,從而破壞合約狀態,這種攻擊手法就是著名的Reentrance Attack(重放攻擊)。

這裡,先引入一個重放攻擊的小實驗,以便讓讀者瞭解為什麼外部呼叫可能導致合約被破壞,同時幫助更好地理解即將介紹的兩種提升合約安全性的設計模式。

關於重放攻擊,這裡舉個精簡的例子。

AddService合約是一個簡單的計數器,每個外部合約可以呼叫AddService合約的addByOne來將欄位_count加一,同時透過require來強制要求每個外部合約最多隻能呼叫一次該函式。

這樣,_count欄位就精確的反應出AddService被多少合約呼叫過。在addByOne函式的末尾,AddService會呼叫外部合約的回撥函式notify。AddService的程式碼如下:

contract AddService{

uint private _count;

mapping(address=>bool) private _adders;

function addByOne() public {

//強制要求每個地址只能呼叫一次

require(_adders[msg.sender] == false, "You have added already");

//計數

_count++;

//呼叫賬戶的回撥函式

AdderInterface adder = AdderInterface(msg.sender);

adder.notify();

//將地址加入已呼叫集合

_adders[msg.sender] = true;

}

}

contract AdderInterface{

function notify() public;

}

如果AddService如此部署,惡意攻擊者可以輕易控制AddService中的_count數目,使該計數器完全失效。

攻擊者只需要部署一個合約BadAdder,就可透過它來呼叫AddService,就可以達到攻擊效果。BadAdder合約如下:

contract BadAdder is AdderInterface{

AddService private _addService = //...;

uint private _calls;

//回撥

function notify() public{

if(_calls > 5){

return;

}

_calls++;

//Attention !!!!!!

_addService.addByOne();

}

function doAdd() public{

_addService.addByOne();

}

}

BadAdder在回撥函式notify中,反過來繼續呼叫AddService,由於AddService糟糕的程式碼設計,require條件檢測語句被輕鬆繞過,攻擊者可以直擊_count欄位,使其被任意地重複新增。

攻擊過程的時序圖如下:

在這個例子中,AddService難以獲知呼叫者的回撥邏輯,但依然輕信了這個外部呼叫,而攻擊者利用了AddService糟糕的程式碼編排,導致悲劇的發生。

本例子中去除了實際的業務意義,攻擊後果僅僅是_count值失真。真正的重放攻擊,可對業務造成嚴重後果。比如在統計投票數目是,投票數會被改得面目全非。

打鐵還需自身硬,如果想遮蔽這類攻擊,合約需要遵循良好的編碼模式,下面將介紹兩個可有效解除此類攻擊的設計模式。

Checks-Effects-Interaction - 保證狀態完整,再做外部呼叫

該模式是編碼風格約束,可有效避免重放攻擊。通常情況下,一個函式可能包含三個部分:

Checks:引數驗證

Effects:修改合約狀態

Interaction:外部互動

這個模式要求合約按照Checks-Effects-Interaction的順序來組織程式碼。它的好處在於進行外部呼叫之前,Checks-Effects已完成合約自身狀態所有相關工作,使得狀態完整、邏輯自洽,這樣外部呼叫就無法利用不完整的狀態進行攻擊了。

回顧前文的AddService合約,並沒有遵循這個規則,在自身狀態沒有更新完的情況下去呼叫了外部程式碼,外部程式碼自然可以橫插一刀,讓_adders[msg.sender]=true永久不被呼叫,從而使require語句失效。我們以checks-effects-interaction的角度審閱原來的程式碼:

//Checks

require(_adders[msg.sender] == false, "You have added already");

//Effects

_count++;

//Interaction

AdderInterface adder = AdderInterface(msg.sender);

adder.notify();

//Effects

_adders[msg.sender] = true;

只要稍微調整順序,滿足Checks-Effects-Interaction模式,悲劇就得以避免:

//Checks

require(_adders[msg.sender] == false, "You have added already");

//Effects

_count++;

_adders[msg.sender] = true;

//Interaction

AdderInterface adder = AdderInterface(msg.sender);

adder.notify();

由於_adders對映已經修改完畢,當惡意攻擊者想遞迴地呼叫addByOne,require這道防線就會起到作用,將惡意呼叫攔截在外。

雖然該模式並非解決重放攻擊的唯一方式,但依然推薦開發者遵循。

Mutex - 禁止遞迴

Mutex模式也是解決重放攻擊的有效方式。它透過提供一個簡單的修飾符來防止函式被遞迴呼叫:

contract Mutex {

bool locked;

modifier noReentrancy() {

//防止遞迴

require(!locked, "Reentrancy detected");

locked = true;

_;

locked = false;

}

//呼叫該函式將會丟擲Reentrancy detected錯誤

function some() public noReentrancy{

some();

}

}

在這個例子中,呼叫some函式前會先執行noReentrancy修飾符,將locked變數賦值為true。如果此時又遞迴地呼叫了some,修飾符的邏輯會再次啟用,由於此時的locked屬性已為true,修飾符的第一行程式碼會丟擲錯誤。

可維護性(Maintaince)

在區塊鏈中,合約一旦部署,就無法更改。當合約出現了bug,通常要面對以下問題:

合約上已有的業務資料怎麼處理?

怎麼儘可能減少升級影響範圍,讓其餘功能不受影響?

依賴它的其他合約該怎麼辦?

回顧面向物件程式設計,其核心思想是將變化的事物和不變的事物相分離,以阻隔變化在系統中的傳播。所以,設計良好的程式碼通常都組織得高度模組化、高內聚低耦合。利用這個經典的思想可解決上面的問題。

Data segregation - 資料與邏輯相分離

瞭解該設計模式之前,先看看下面這個合約程式碼:

contract Computer{

uint private _data;

function setData(uint data) public {

_data = data;

}

function compute() public view returns(uint){

return _data * 10;

}

}

此合約包含兩個能力,一個是儲存資料(setData函式),另一個是運用資料進行計算(compute函式)。如果合約部署一段時間後,發現compute寫錯了,比如不應是乘以10,而要乘以20,就會引出前文如何升級合約的問題。

這時,可以部署一個新合約,並嘗試將已有資料遷移到新的合約上,但這是一個很重的操作,一方面要編寫遷移工具的程式碼,另一方面原先的資料完全作廢,空佔著寶貴的節點儲存資源。

所以,預先在程式設計時進行模組化十分必要。如果我們將"資料"看成不變的事物,將"邏輯"看成可能改變的事物,就可以完美避開上述問題。Data Segregation(意為資料分離)模式很好地實現了這一想法。

該模式要求一個業務合約和一個數據合約:資料合約只管資料存取,這部分是穩定的;而業務合約則透過資料合約來完成邏輯操作。

結合前面的例子,我們將資料讀寫操作專門轉移到一個合約DataRepository中:

contract DataRepository{

uint private _data;

function setData(uint data) public {

_data = data;

}

function getData() public view returns(uint){

return _data;

}

}

計算功能被單獨放入一個業務合約中:

contract Computer{

DataRepository private _dataRepository;

constructor(address addr){

_dataRepository =DataRepository(addr);

}

//業務程式碼

function compute() public view returns(uint){

return _dataRepository.getData() * 10;

}

}

這樣,只要資料合約是穩定的,業務合約的升級就很輕量化了。比如,當我要把Computer換成ComputerV2時,原先的資料依然可以被複用。

Satellite - 分解合約功能

一個複雜的合約通常由許多功能構成,如果這些功能全部耦合在一個合約中,當某一個功能需要更新時,就不得不去部署整個合約,正常的功能都會受到波及。

Satellite模式運用單一職責原則解決上述問題,提倡將合約子功能放到子合約裡,每個子合約(也稱為衛星合約)只對應一個功能。當某個子功能需要修改,只要建立新的子合約,並將其地址更新到主合約裡即可,其餘功能不受影響。

舉個簡單的例子,下面這個合約的setVariable功能是將輸入資料進行計算(compute函式),並將計算結果存入合約狀態_variable:

contract Base {

uint public _variable;

function setVariable(uint data) public {

_variable = compute(data);

}

//計算

function compute(uint a) internal returns(uint){

return a * 10;

}

}

如果部署後,發現compute函式寫錯,希望乘以的係數是20,就要重新部署整個合約。但如果一開始按照Satellite模式操作,則只需部署相應的子合約。

首先,我們先將compute函式剝離到一個單獨的衛星合約中去:

contract Satellite {

function compute(uint a) public returns(uint){

return a * 10;

}

}

然後,主合約依賴該子合約完成setVariable:

contract Base {

uint public _variable;

function setVariable(uint data) public {

_variable = _satellite.compute(data);

}

Satellite _satellite;

//更新子合約(衛星合約)

function updateSatellite(address addr) public {

_satellite = Satellite(addr);

}

}

這樣,當我們需要修改compute函式時,只需部署這樣一個新合約,並將它的地址傳入到Base.updateSatellite即可:

contract Satellite2{

function compute(uint a) public returns(uint){

return a * 20;

}

}

Contract Registry - 跟蹤最新合約

在Satellite模式中,如果一個主合約依賴子合約,在子合約升級時,主合約需要更新對子合約的地址引用,這透過updateXXX來完成,例如前文的updateSatellite函式。

這類介面屬於維護性介面,與實際業務無關,過多暴露此類介面會影響主合約美觀,讓呼叫者的體驗大打折扣。Contract Registry設計模式優雅地解決了這個問題。

在該設計模式下,會有一個專門的合約Registry跟蹤子合約的每次升級情況,主合約可透過查詢此Registyr合約取得最新的子合約地址。衛星合約重新部署後,新地址透過Registry.update函式來更新。

contract Registry{

address _current;

address[] _previous;

//子合約升級了,就透過update函式更新地址

function update(address newAddress) public{

if(newAddress != _current){

_previous.push(_current);

_current = newAddress;

}

}

function getCurrent() public view returns(address){

return _current;

}

}

主合約依賴於Registry獲取最新的衛星合約地址。

contract Base {

uint public _variable;

function setVariable(uint data) public {

Satellite satellite = Satellite(_registry.getCurrent());

_variable = satellite.compute(data);

}

Registry private _registry = //...;

}

Contract Relay - 代理呼叫最新合約

該設計模式所解決問題與Contract Registry一樣,即主合約無需暴露維護性介面就可呼叫最新子合約。該模式下,存在一個代理合約,和子合約享有相同介面,負責將主合約的呼叫請求傳遞給真正的子合約。衛星合約重新部署後,新地址透過SatelliteProxy.update函式來更新。

contract SatelliteProxy{

address _current;

function compute(uint a) public returns(uint){

Satellite satellite = Satellite(_current);

return satellite.compute(a);

}

//子合約升級了,就透過update函式更新地址

function update(address newAddress) public{

if(newAddress != _current){

_current = newAddress;

}

}

}

contract Satellite {

function compute(uint a) public returns(uint){

return a * 10;

}

}

主合約依賴於SatelliteProxy:

contract Base {

uint public _variable;

function setVariable(uint data) public {

_variable = _proxy.compute(data);

}

SatelliteProxy private _proxy = //...;

}

生命週期(Lifecycle)

在預設情況下,一個合約的生命週期近乎無限——除非賴以生存的區塊鏈被消滅。但很多時候,使用者希望縮短合約的生命週期。這一節將介紹兩個簡單模式提前終結合約生命。

Mortal - 允許合約自毀

位元組碼中有一個selfdestruct指令,用於銷燬合約。所以只需要暴露出自毀介面即可:

contract Mortal{

//自毀

function destroy() public{

selfdestruct(msg.sender);

}

}

Automatic Deprecation - 允許合約自動停止服務

如果你希望一個合約在指定期限後停止服務,而不需要人工介入,可以使用Automatic Deprecation模式。

contract AutoDeprecated{

uint private _deadline;

function setDeadline(uint time) public {

_deadline = time;

}

modifier notExpired(){

require(now <= _deadline);

_;

}

function service() public notExpired{

//some code

}

}

當用戶呼叫service,notExpired修飾符會先進行日期檢測,這樣,一旦過了特定時間,呼叫就會因過期而被攔截在notExpired層。

許可權(Authorization)

前文中有許多管理性介面,這些介面如果任何人都可呼叫,會造成嚴重後果,例如上文中的自毀函式,假設任何人都能訪問,其嚴重性不言而喻。所以,一套保證只有特定賬戶能夠訪問的許可權控制設計模式顯得尤為重要。

Ownership

對於許可權的管控,可以採用Ownership模式。該模式保證了只有合約的擁有者才能呼叫某些函式。首先需要有一個Owned合約:

contract Owned{

address public _owner;

constructor() {

_owner = msg.sender;

}

modifier onlyOwner(){

require(_owner == msg.sender);

_;

}

}

如果一個業務合約,希望某個函式只由擁有者呼叫,該怎麼辦呢?如下:

contract Biz is Owned{

function manage() public onlyOwner{

}

}

這樣,當呼叫manage函式時,onlyOwner修飾符就會先執行並檢測呼叫者是否與合約擁有者一致,從而將無授權的呼叫攔截在外。

行為控制(Action And Control)

這類模式一般針對具體場景使用,這節將主要介紹基於隱私的編碼模式和與鏈外資料互動的設計模式。

Commit - Reveal - 延遲秘密洩露

鏈上資料都是公開透明的,一旦某些隱私資料上鍊,任何人都可看到,並且再也無法撤回。

Commit And Reveal模式允許使用者將要保護的資料轉換為不可識別資料,比如一串雜湊值,直到某個時刻再揭示雜湊值的含義,展露真正的原值。

以投票場景舉例,假設需要在所有參與者都完成投票後再揭示投票內容,以防這期間參與者受票數影響。我們可以看看,在這個場景下所用到的具體程式碼:

contract CommitReveal {

struct Commit {

string choice;

string secret;

uint status;

}

mapping(address => mapping(bytes32 => Commit)) public userCommits;

event LogCommit(bytes32, address);

event LogReveal(bytes32, address, string, string);

function commit(bytes32 commit) public {

Commit storage userCommit = userCommits[msg.sender][commit];

require(userCommit.status == 0);

userCommit.status = 1; // comitted

emit LogCommit(commit, msg.sender);

}

function reveal(string choice, string secret, bytes32 commit) public {

Commit storage userCommit = userCommits[msg.sender][commit];

require(userCommit.status == 1);

require(commit == keccak256(choice, secret));

userCommit.choice = choice;

userCommit.secret = secret;

userCommit.status = 2;

emit LogReveal(commit, msg.sender, choice, secret);

}

}

Oracle - 讀取鏈外資料

目前,鏈上的智慧合約生態相對封閉,無法獲取鏈外資料,影響了智慧合約的應用範圍。

鏈外資料可極大擴充套件智慧合約的使用範圍,比如在保險業中,如果智慧合約可讀取到現實發生的意外事件,就可自動執行理賠。

獲取外部資料會透過名為Oracle的鏈外資料層來執行。當業務方的合約嘗試獲取外部資料時,會先將查詢請求存入到某個Oracle專用合約內;Oracle會監聽該合約,讀取到這個查詢請求後,執行查詢,並呼叫業務合約響應介面使合約獲取結果。

下面定義了一個Oracle合約:

contract Oracle {

address oracleSource = 0x123; // known source

struct Request {

bytes data;

function(bytes memory) external callback;

}

Request[] requests;

event NewRequest(uint);

modifier onlyByOracle() {

require(msg.sender == oracleSource); _;

}

function query(bytes data, function(bytes memory) external callback) public {

requests.push(Request(data, callback));

emit NewRequest(requests.length - 1);

}

//回撥函式,由Oracle呼叫

function reply(uint requestID, bytes response) public onlyByOracle() {

requests[requestID].callback(response);

}

}

業務方合約與Oracle合約進行互動:

contract BizContract {

Oracle _oracle;

constructor(address oracle){

_oracle = Oracle(oracle);

}

modifier onlyByOracle() {

require(msg.sender == address(_oracle));

_;

}

function updateExchangeRate() {

_oracle.query("USD", this.oracleResponse);

}

//回撥函式,用於讀取響應

function oracleResponse(bytes response) onlyByOracle {

// use the data

}

}

上鍊的原則

“如無必要,勿增實體”。

基於區塊鏈技術及智慧合約發展現狀,資料的上鍊需遵循以下原則:

需要分散式協作的重要資料才上鍊,非必需資料不上鍊;

敏感資料脫敏或加密後上鏈(視資料保密程度選擇符合隱私保護安全等級要求的加密演算法);

鏈上驗證,鏈下授權。

在使用區塊鏈時,開發者不需要將所有業務和資料都放到鏈上。相反,“好鋼用在刀刃上”,智慧合約更適合被用在分散式協作的業務場景中。

精簡函式變數

如果在智慧合約中定義了複雜的邏輯,特別是合約內定義了複雜的函式入參、變數和返回值,就會在編譯的時候碰到以下錯誤:

Compiler error: Stack too deep, try removing local variables.

這也是社群中的高頻技術問題之一。造成這個問題的原因就是EVM所設計用於最大的棧深度為16。

所有的計算都在一個棧內執行,對棧的訪問只限於其頂端,限制方式為:允許複製最頂端16個元素中的一個到棧頂,或者將棧頂元素和下面16個元素中的一個交換。

所有其他操作都只能取最頂的幾個元素,運算後,把結果壓入棧頂。當然可以把棧上的元素放到儲存或記憶體中。但無法只訪問棧上指定深度的那個元素,除非先從棧頂移除其他元素。如果一個合約中,入參、返回值、內部變數的大小超過了16個,顯然就超出了棧的最大深度。

因此,我們可以使用結構體或陣列來封裝入參或返回值,達到減少棧頂元素使用的目的,從而避免此錯誤。

例如以下程式碼,透過使用bytes陣列來封裝了原本16個bytes變數。

function doBiz(bytes[] paras) public {

require(paras.length >= 16);

// do something

}

保證引數和行為符合預期

心懷“Code is law”的遠大理想,極客們設計和創造了區塊鏈的智慧合約。

在聯盟鏈中,不同的參與者可以使用智慧合約來定義和書寫一部分業務或互動的邏輯,以完成部分社會或商業活動。

相比於傳統軟體開發,智慧合約對函式引數和行為的安全性要求更為嚴格。在聯盟鏈中提供了身份實名和CA證書等機制,可以有效定位和監管所有參與者。不過,智慧合約缺乏對漏洞和攻擊的事前干預機制。正所謂字字珠璣,如果不嚴謹地檢查智慧合約輸入引數或行為,有可能會觸發一些意想不到的bug。

因此,在編寫智慧合約時,一定要注意對合約引數和行為的檢查,尤其是那些對外部開放的合約函式。

Solidity提供了require、revert、assert等關鍵字來進行異常的檢測和處理。一旦檢測並發現錯誤,整個函式呼叫會被回滾,所有狀態修改都會被回退,就像從未呼叫過函式一樣。

以下分別使用了三個關鍵字,實現了相同的語義。

require(_data == data, "require data is valid");

if(_data != data) { revert("require data is valid"); }

assert(_data == data);

不過,這三個關鍵字一般適用於不同的使用場景:

require:最常用的檢測關鍵字,用來驗證輸入引數和呼叫函式結果是否合法。

revert:適用在某個分支判斷的場景下。

assert: 檢查結果是否正確、合法,一般用於函式結尾。

在一個合約的函式中,可以使用函式修飾器來抽象部分引數和條件的檢查。在函式體內,可以對執行狀態使用if-else等判斷語句進行檢查,對異常的分支使用revert回退。在函式執行結束前,可以使用assert對執行結果或中間狀態進行斷言檢查。

在實踐中,推薦使用require關鍵字,並將條件檢查移到函式修飾器中去;這樣可以讓函式的職責更為單一,更專注到業務邏輯中。同時,函式修飾器等條件程式碼也更容易被複用,合約也會更加安全、層次化。

在本文中,我們以一個水果店庫存管理系統為例,設計一個水果超市的合約。這個合約只包含了對店內所有水果品類和庫存數量的管理,setFruitStock函式提供了對應水果庫存設定的函式。在這個合約中,我們需要檢查傳入的引數,即水果名稱不能為空。

pragma solidity ^0.4.25;

contract FruitStore {

mapping(bytes => uint) _fruitStock;

modifier validFruitName(bytes fruitName) {

require(fruitName.length > 0, "fruite name is invalid!");

_;

}

function setFruitStock(bytes fruitName, uint stock) validFruitName(fruitName) external {

_fruitStock[fruitName] = stock;

}

}

如上所述,我們添加了函式執行前的引數檢查的函式修飾器。同理,透過使用函式執行前和函式執行後檢查的函式修飾器,可以保證智慧合約更加安全、清晰。智慧合約的編寫需要設定嚴格的前置和後置函式檢查,來保證其安全性。

嚴控函式的執行許可權

如果說智慧合約的引數和行為檢測提供了靜態的合約安全措施,那麼合約許可權控制的模式則提供了動態訪問行為的控制。

由於智慧合約是釋出到區塊鏈上,所有資料和函式對所有參與者都是公開透明的,任一節點參與者都可發起交易,無法保證合約的隱私。因此,合約釋出者必須對函式設計嚴格的訪問限制機制。

Solidity提供了函式可見性修飾符、修飾器等語法,靈活地使用這些語法,可幫助構建起合法授權、受控呼叫的智慧合約系統。

還是以剛才的水果合約為例。現在getStock提供了查詢具體水果庫存數量的函式。

pragma solidity ^0.4.25;

contract FruitStore {

mapping(bytes => uint) _fruitStock;

modifier validFruitName(bytes fruitName) {

require(fruitName.length > 0, "fruite name is invalid!");

_;

}

function getStock(bytes fruit) external view returns(uint) {

return _fruitStock[fruit];

}

function setFruitStock(bytes fruitName, uint stock) validFruitName(fruitName) external {

_fruitStock[fruitName] = stock;

}

}

水果店老闆將這個合約釋出到了鏈上。但是,釋出之後,setFruitStock函式可被任何其他聯盟鏈的參與者呼叫。

雖然聯盟鏈的參與者是實名認證且可事後追責;但一旦有惡意攻擊者對水果店發起攻擊,呼叫setFruitStock函式就能任意修改水果庫存,甚至將所有水果庫存清零,這將對水果店正常經營管理產生嚴重後果。

因此,設定某些預防和授權的措施很必要:對於修改庫存的函式setFruitStock,可在函式執行前對呼叫者進行鑑權。

類似的,這些檢查可能會被多個修改資料的函式複用,使用一個onlyOwner的修飾器就可以抽象此檢查。_owner欄位代表了合約的所有者,會在合約建構函式中被初始化。使用public修飾getter查詢函式,就可以透過_owner()函式查詢合約的所有者。

contract FruitStore {

address public _owner;

mapping(bytes => uint) _fruitStock;

constructor() public {

_owner = msg.sender;

}

modifier validFruitName(bytes fruitName) {

require(fruitName.length > 0, "fruite name is invalid!");

_;

}

// 鑑權函式修飾器

modifier onlyOwner() {

require(msg.sender == _owner, "Auth: only owner is authorized.");

_;

}

function getStock(bytes fruit) external view returns(uint) {

return _fruitStock[fruit];

}

// 添加了onlyOwner修飾器

function setFruitStock(bytes fruitName, uint stock)

onlyOwner validFruitName(fruitName) external {

_fruitStock[fruitName] = stock;

}

}

這樣一來,我們可以將相應的函式呼叫許可權檢查封裝到修飾器中,智慧合約會自動發起對呼叫者身份驗證檢查,並且只允許合約部署者來呼叫setFruitStock函式,以此保證合約函式向指定呼叫者開放。

抽象通用的業務邏輯

分析上述FruitStore合約,我們發現合約裡似乎混入了奇怪的東西。參考單一職責的程式設計原則,水果店庫存管理合約多了上述函式功能檢查的邏輯,使合約無法將所有程式碼專注在自身業務邏輯中。

對此,我們可以抽象出可複用的功能,利用Solidity的繼承機制繼承最終抽象的合約。

基於上述FruitStore合約,可抽象出一個BasicAuth合約,此合約包含之前onlyOwner的修飾器和相關功能介面。

contract BasicAuth {

address public _owner;

constructor() public {

_owner = msg.sender;

}

function setOwner(address owner)

public

onlyOwner

{

_owner = owner;

}

modifier onlyOwner() {

require(msg.sender == _owner, "BasicAuth: only owner is authorized.");

_;

}

}

FruitStore可以複用這個修飾器,並將合約程式碼收斂到自身業務邏輯中。

import "./BasicAuth.sol";

contract FruitStore is BasicAuth {

mapping(bytes => uint) _fruitStock;

function setFruitStock(bytes fruitName, uint stock)

onlyOwner validFruitName(fruitName) external {

_fruitStock[fruitName] = stock;

}

}

這樣一來,FruitStore的邏輯被大大簡化,合約程式碼更精簡、聚焦和清晰。

預防私鑰的丟失

在區塊鏈中呼叫合約函式的方式有兩種:內部呼叫和外部呼叫。

出於隱私保護和許可權控制,業務合約會定義一個合約所有者。假設使用者A部署了FruitStore合約,那上述合約owner就是部署者A的外部賬戶地址。這個地址由外部賬戶的私鑰計算生成。

但是,在現實世界中,私鑰洩露、丟失的現象比比皆是。一個商用區塊鏈DAPP需要嚴肅考慮私鑰的替換和重置等問題。

這個問題最為簡單直觀的解決方法是新增一個備用私鑰。這個備用私鑰可支援許可權合約修改owner的操作,程式碼如下:

contract BasicAuth {

address public _owner;

address public _bakOwner;

constructor(address bakOwner) public {

_owner = msg.sender;

_bakOwner = bakOwner;

}

function setOwner(address owner)

public

canSetOwner

{

_owner = owner;

}

function setBakOwner(address owner)

public

canSetOwner

{

_bakOwner = owner;

}

// ...

modifier isAuthorized() {

require(msg.sender == _owner || msg.sender == _bakOwner, "BasicAuth: only owner or back owner is authorized.");

_;

}

}

這樣,當發現私鑰丟失或洩露時,我們可以使用備用外部賬戶呼叫setOwner重置賬號,恢復、保障業務正常執行。

面向介面程式設計

上述私鑰備份理念值得推崇,不過其具體實現方式存在一定侷限性,在很多業務場景下,顯得過於簡單粗暴。

對於實際的商業場景,私鑰的備份和儲存需要考慮的維度和因素要複雜得多,對應金鑰備份策略也更多元化。

以水果店為例,有的連鎖水果店可能希望透過品牌總部來管理私鑰,也有的可能透過社交關係重置帳號,還有的可能會繫結一個社交平臺的管理帳號……

面向介面程式設計,而不依賴具體的實現細節,可以有效規避這個問題。例如,我們利用介面功能首先定義一個判斷許可權的抽象介面:

contract Authority {

function canCall(

address src, address dst, bytes4 sig

) public view returns (bool);

}

這個canCall函式涵蓋了函式呼叫者地址、目標呼叫合約的地址和函式簽名,函式返回一個bool的結果。這包含了合約鑑權所有必要的引數。

我們可進一步修改之前的許可權管理合約,並在合約中依賴Authority介面,當鑑權時,修飾器會呼叫介面中的抽象方法:

contract BasicAuth {

Authority public _authority;

function setAuthority(Authority authority)

public

auth

{

_authority = authority;

}

modifier isAuthorized() {

require(auth(msg.sender, msg.sig), "BasicAuth: only owner or back owner is authorized.");

_;

}

function auth(address src, bytes4 sig) public view returns (bool) {

if (src == address(this)) {

return true;

} else if (src == _owner) {

return true;

} else if (_authority == Authority(0)) {

return false;

} else {

return _authority.canCall(src, this, sig);

}

}

}

這樣,我們只需要靈活定義實現了canCall介面的合約,在合約的canCall方法中定義具體判斷邏輯。而業務合約,例如FruitStore繼承BasicAuth合約,在建立時只要傳入具體的實現合約,就可以實現不同判斷邏輯。

合理預留事件

迄今為止,我們已實現強大靈活的許可權管理機制,只有預先授權的外部賬戶才能修改合約owner屬性。

不過,僅透過上述合約程式碼,我們無法記錄和查詢修改、呼叫函式的歷史記錄和明細資訊。而這樣的需求在實際業務場景中比比皆是。比如,FruitStore水果店需要透過查詢歷史庫存修改記錄,計算出不同季節的暢銷與滯銷水果。

一種方法是依託鏈下維護獨立的臺賬機制。不過,這種方法存在很多問題:保持鏈下臺賬和鏈上記錄一致的成本開銷非常高;同時,智慧合約面向鏈上所有參與者開放,一旦其他參與者呼叫了合約函式,相關交易資訊就存在不能同步的風險。

針對此類場景,Solidity提供了event語法。event不僅具備可供SDK監聽回撥的機制,還能用較低的gas成本將事件引數等資訊完整記錄、儲存到區塊中。FISCO BCOS社群中,也有WEBASE-Collect-Bee這樣的工具,在事後實現區塊歷史事件資訊的完整匯出。

WEBASE-Collect-Bee工具參考連結如下:

https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Collect-Bee/index.html

基於上述許可權管理合約,我們可以定義相應的修改許可權事件,其他事件以此類推。

event LogSetAuthority (Authority indexed authority, address indexed from);

}

接下來,可以呼叫相應的事件:

function setAuthority(Authority authority)

public

auth

{

_authority = authority;

emit LogSetAuthority(authority, msg.sender);

}

當setAuthority函式被呼叫時,會同時觸發LogSetAuthority,將事件中定義的Authority合約地址以及呼叫者地址記錄到區塊鏈交易回執中。當透過控制檯呼叫setAuthority方法時,對應事件LogSetAuthority也會被打印出來。

基於WEBASE-Collect-Bee,我們可以匯出所有該函式的歷史資訊到資料庫中。也可基於WEBASE-Collect-Bee進行二次開發,實現複雜的資料查詢、大資料分析和資料視覺化等功能。

遵循安全程式設計規範

每一門語言都有其相應的編碼規範,我們需要儘可能嚴格地遵循Solidity官方程式設計風格指南,使程式碼更利於閱讀、理解和維護,有效地減少合約的bug數量。

Solidity官方程式設計風格指南參考連結如下:

https://solidity.readthedocs.io/en/latest/style-guide.html

除了程式設計規範,業界也總結了很多安全程式設計指南,例如重入漏洞、資料結構溢位、隨機數誤區、建構函式失控、為初始化的儲存指標等等。重視和防範此類風險,採用業界推薦的安全程式設計規範至關重要,例如Solidity官方安全程式設計指南。參考連結如下:

https://solidity.readthedocs.io/en/latest/security-considerations.html

同時,在合約釋出上線後,還需要注意關注、訂閱Solidity社群內安全組織或機構釋出的各類安全漏洞、攻擊手法,一旦出現問題,及時做到亡羊補牢。

對於重要的智慧合約,有必要引入審計。現有的審計包括了人工審計、機器審計等方法,透過程式碼分析、規則驗證、語義驗證和形式化驗證等方法保證合約安全性。

雖然本文通篇都在強調,模組化和重用被嚴格審查並廣泛驗證的智慧合約是最佳的實踐策略。但在實際開發過程,這種假設過於理想化,每個專案或多或少都會引入新的程式碼,甚至從零開始。

不過,我們仍然可以視程式碼的複用程度進行審計分級,顯式地標註出引用的程式碼,將審計和檢查的重點放在新程式碼上,以節省審計成本。

最後,“前事不忘後事之師”,我們需要不斷總結和學習前人的最佳實踐,動態和可持續地提升編碼工程水平,並不斷應用到具體實踐中。

積累和複用成熟的程式碼

前文面向介面程式設計中的思想可降低程式碼耦合,使合約更容易擴充套件、利於維護。在遵循這條規則之外,還有另外一條忠告:儘可能地複用現有程式碼庫。

智慧合約釋出後難以修改或撤回,而且釋出到公開透明的區塊鏈環境上,就意味著一旦出現bug造成的損失和風險更甚於傳統軟體。因此,複用一些更好更安全的輪子遠勝過重新造輪子。

在開源社群中,已經存在大量的業務合約和庫可供使用,例如OpenZeppelin等優秀的庫。

如果在開源世界和過去團隊的程式碼庫裡找不到合適的可複用程式碼,建議在編寫新程式碼時儘可能地測試和完善程式碼設計。此外,還要定期分析和審查歷史合約程式碼,將其模板化,以便於擴充套件和複用。

例如,針對上面的BasicAuth,參考防火牆經典的ACL(Access Control List)設計,我們可以進一步地繼承和擴充套件BasicAuth,抽象出ACL合約控制的實現。

contract AclGuard is BasicAuth {

bytes4 constant public ANY_SIG = bytes4(uint(-1));

address constant public ANY_ADDRESS = address(bytes20(uint(-1)));

mapping (address => mapping (address => mapping (bytes4 => bool))) _acl;

function canCall(

address src, address dst, bytes4 sig

) public view returns (bool) {

return _acl[src][dst][sig]

|| _acl[src][dst][ANY_SIG]

|| _acl[src][ANY_ADDRESS][sig]

|| _acl[src][ANY_ADDRESS][ANY_SIG]

|| _acl[ANY_ADDRESS][dst][sig]

|| _acl[ANY_ADDRESS][dst][ANY_SIG]

|| _acl[ANY_ADDRESS][ANY_ADDRESS][sig]

|| _acl[ANY_ADDRESS][ANY_ADDRESS][ANY_SIG];

}

function permit(address src, address dst, bytes4 sig) public onlyAuthorized {

_acl[src][dst][sig] = true;

emit LogPermit(src, dst, sig);

}

function forbid(address src, address dst, bytes4 sig) public onlyAuthorized {

_acl[src][dst][sig] = false;

emit LogForbid(src, dst, sig);

}

function permit(address src, address dst, string sig) external {

permit(src, dst, bytes4(keccak256(sig)));

}

function forbid(address src, address dst, string sig) external {

forbid(src, dst, bytes4(keccak256(sig)));

}

function permitAny(address src, address dst) external {

permit(src, dst, ANY_SIG);

}

function forbidAny(address src, address dst) external {

forbid(src, dst, ANY_SIG);

}

}

在這個合約裡,有呼叫者地址、被呼叫合約地址和函式簽名三個主要引數。透過配置ACL的訪問策略,可以精確地定義和控制函式訪問行為及許可權。合約內建了ANY的常量,匹配任意函式,使訪問粒度的控制更加便捷。這個模板合約實現了強大靈活的功能,足以滿足所有類似許可權控制場景的需求。

提升儲存和計算的效率

迄今為止,在上述的推演過程中,更多的是對智慧合約程式設計做加法。但相比傳統軟體環境,智慧合約上的儲存和計算資源更加寶貴。因此,如何對合約做減法也是用好Solidity的必修課程之一。

選取合適的變數型別

顯式的問題可透過EVM編譯器檢測出來並報錯;但大量的效能問題可能被隱藏在程式碼的細節中。

Solidity提供了非常多精確的基礎型別,這與傳統的程式語言大相徑庭。下面有幾個關於Solidity基礎型別的小技巧。

在C語言中,可以用short\int\long按需定義整數型別,而到了Solidity,不僅區分int和uint,甚至還能定義uint的長度,比如uint8是一個位元組,uint256是32個位元組。這種設計告誡我們,能用uint8搞定的,絕對不要用uint16!

幾乎所有Solidity的基本型別,都能在宣告時指定其大小。開發者一定要有效利用這一語法特性,編寫程式碼時只要滿足需求就儘可能選取小的變數型別。

資料型別bytes32可存放 32 個(原始)位元組,但除非資料是bytes32或bytes16這類定長的資料型別,否則更推薦使用長度可以變化的bytes。bytes類似byte[],但在外部函式中會自動壓縮打包,更節省空間。

如果變數內容是英文的,不需要採用UTF-8編碼,在這裡,推薦bytes而不是string。string預設採用UTF-8編碼,所以相同字串的儲存成本會高很多。

緊湊狀態變數打包

除了儘可能使用較小的資料型別來定義變數,有的時候,變數的排列順序也非常重要,可能會影響到程式執行和儲存效率。

其中根本原因還是EVM,不管是EVM儲存插槽(Storage Slot)還是棧,每個元素長度是一個字(256位,32位元組)。

分配儲存時,所有變數(除了對映和動態陣列等非靜態型別)都會按宣告順序從位置0開始依次寫下。

在處理狀態變數和結構體成員變數時,EVM會將多個元素打包到一個儲存插槽中,從而將多個讀或寫合併到一次對儲存的操作中。

值得注意的是,使用小於32 位元組的元素時,合約的gas使用量可能高於使用32位元組元素時。這是因為EVM每次會操作32個位元組,所以如果元素比32位元組小,必須使用更多的操作才能將其大小縮減到所需。這也解釋了Solidity中最常見的資料型別,例如int,uint,byte32,為何都剛好佔用32個位元組。

所以,當合約或結構體宣告多個狀態變數時,能否合理地組合安排多個儲存狀態變數和結構體成員變數,使之佔用更少的儲存位置就十分重要。

例如,在以下兩個合約中,經過實際測試,Test1合約比Test2合約佔用更少的儲存和計算資源。

contract Test1 {

//佔據2個slot, "gasUsed":188873

struct S {

bytes1 b1;

bytes31 b31;

bytes32 b32;

}

S s;

function f() public {

S memory tmp = S("a","b","c");

s = tmp;

}

}

contract Test2 {

//佔據1個slot, "gasUsed":188937

struct S {

bytes31 b31;

bytes32 b32;

bytes1 b1;

}

// ……

}

最佳化查詢介面

查詢介面的最佳化點很多,比如一定要在只負責查詢的函式宣告中新增view修飾符,否則查詢函式會被當成交易打包併發送到共識佇列,被全網執行並被記錄在區塊中;這將大大增加區塊鏈的負擔,佔用寶貴的鏈上資源。

再如,不要在智慧合約中新增複雜的查詢邏輯,因為任何複雜查詢程式碼都會使整個合約變得更長更復雜。讀者可使用上文提及的WeBASE資料匯出元件,將鏈上資料匯出到資料庫中,在鏈下進行查詢和分析。

縮減合約binary長度

開發者編寫的Solidity程式碼會被編譯為binary code,而部署智慧合約的過程實際上就是透過一個transaction將binary code儲存在鏈上,並取得專屬於該合約的地址。

縮減binary code的長度可節省網路傳輸、共識打包資料儲存的開銷。例如,在典型的存證業務場景中,每次客戶存證都會新建一個存證合約,因此,應當儘可能地縮減binary code的長度。

常見思路是裁剪不必要的邏輯,刪掉冗餘程式碼。特別是在複用程式碼時,可能引入一些非剛需程式碼。以上文ACL合約為例,支援控制合約函式粒度的許可權。

function canCall(

address src, address dst, bytes4 sig

) public view returns (bool) {

return _acl[src][dst][sig]

|| _acl[src][dst][ANY_SIG]

|| _acl[src][ANY_ADDRESS][sig]

|| _acl[src][ANY_ADDRESS][ANY_SIG]

|| _acl[ANY_ADDRESS][dst][sig]

|| _acl[ANY_ADDRESS][dst][ANY_SIG]

|| _acl[ANY_ADDRESS][ANY_ADDRESS][sig]

|| _acl[ANY_ADDRESS][ANY_ADDRESS][ANY_SIG];

}

function canCall(

address src, address dst

) public view returns (bool) {

return _acl[src][dst]

|| _acl[src][ANY_ADDRESS]

|| _acl[ANY_ADDRESS][dst];

}

另一種縮減binary code的思路是採用更緊湊的寫法。

經實測,採取如上短路原則的判斷語句,其binary長度會比採用if-else語法的更短。同樣,採用if-else的結構,也會比if-if-if的結構生成更短的binary code。

最後,在對binary code長度有極致要求的場景中,應當儘可能避免在合約中新建合約,這會顯著增加binary的長度。例如,某個合約中有如下的建構函式:

constructor() public {

// 在構造器內新建一個新物件

_a = new A();

}

我們可以採用在鏈下構造A物件,並基於address傳輸和固定校驗的方式,來規避這一問題。

constructor(address a) public {

A _a = A(a);

require(_a._owner == address(this));

}

當然,這樣也可能會使合約互動方式變得複雜。但其提供了有效縮短binary code長度的捷徑,需要在具體業務場景中做權衡取捨。

保證合約可升級

經典的三層結構

透過前文方式,我們盡最大努力保持合約設計的靈活性;翻箱倒櫃複用了輪子;也對釋出合約進行全方位、無死角的測試。除此之外,隨著業務需求變化,我們還將面臨一個問題:如何保證合約平滑、順利的升級?

作為一門高階程式語言,Solidity支援執行一些複雜控制和計算邏輯,也支援儲存智慧合約執行後的狀態和業務資料。不同於WEB開發等場景的應用-資料庫分層架構,Solidity語言甚至沒有抽象出一層獨立的資料儲存結構,資料都被儲存到了合約中。

但是,一旦合約需要升級,這種模式就會出現瓶頸。

在Solidity中,一旦合約部署釋出後,其程式碼就無法被修改,只能透過釋出新合約去改動程式碼。假如資料儲存在老合約,就會出現所謂的“孤兒資料”問題,新合約將丟失之前執行的歷史業務資料。

這種情況,開發者可以考慮將老合約資料遷移到新合約中,但此操作至少存在兩個問題:

遷移資料會加重區塊鏈的負擔,產生資源浪費和消耗,甚至引入安全問題;

牽一髮而動全身,會引入額外的遷移資料邏輯,增加合約複雜度。

一種更合理的方式是抽象一層獨立的合約儲存層。這個儲存層只提供合約讀寫的最基本方法,而不包含任何業務邏輯。

在這種模式中,存在三種合約角色:

資料合約:在合約中儲存資料,並提供資料的操作介面。

管理合約:設定控制權限,保證只有控制合約才有許可權修改資料合約。

控制合約:真正需要對資料發起操作的合約。

具體的程式碼示例如下:

資料合約:

contract FruitStore is BasicAuth {

address _latestVersion;

mapping(bytes => uint) _fruitStock;

modifier onlyLatestVersion() {

require(msg.sender == _latestVersion);

_;

}

function upgradeVersion(address newVersion) public {

require(msg.sender == _owner);

_latestVersion = newVersion;

}

function setFruitStock(bytes fruit, uint stock) onlyLatestVersion external {

_fruitStock[fruit] = stock;

}

}

管理合約:

contract Admin is BasicAuth {

function upgradeContract(FruitStore fruitStore, address newController) isAuthorized external {

fruitStore.upgradeVersion(newController);

}

}

控制合約:

contract FruitStoreController is BasicAuth {

function upgradeStock(bytes fruit, uint stock) isAuthorized external {

fruitStore.setFruitStock(fruit, stock);

}

}

一旦函式的控制邏輯需要變更,開發者只需修改FruitStoreController控制合約邏輯,部署一個新合約,然後使用管理合約Admin修改新的合約地址引數就可輕鬆完成合約升級。這種方法可消除合約升級中因業務控制邏輯改變而導致的資料遷移隱患。

但天下沒有免費的午餐,這種操作需要在可擴充套件性和複雜性之間需要做基本的權衡。首先,資料和邏輯的分離降低了執行效能。其次,進一步封裝增加了程式複雜度。最後,越是複雜的合約越會增加潛在攻擊面,簡單的合約比複雜的合約更安全。

通用資料結構

到目前為止,還存在一個問題,假如資料合約中的資料結構本身需要升級怎麼辦?

例如,在FruitStore中,原本只儲存了庫存資訊,現在由於水果銷售店生意發展壯大,一共開了十家分店,需要記錄每家分店、每種水果的庫存和售出資訊。

在這種情況下,一種解決方案是採用外部關聯管理方式:建立一個新的ChainStore合約,在這個合約中建立一個mapping,建立分店名和FruitStore的關係。

此外,不同分店需要建立一個FruitStore的合約。為了記錄新增的售出資訊等資料,我們還需要新建一個合約來管理。

假如在FruitStore中可預設一些不同型別的reserved欄位,可幫助規避新建售出資訊合約的開銷,仍然複用FruitStore合約。但這種方式在最開始會增加儲存開銷。

一種更好的思路是抽象一層更為底層和通用的儲存結構。

程式碼如下:

contract commonDB is BasicAuth {

mapping(bytes => uint) _uintMapping;

function getUint(bytes key) external view returns(uint) {

return _uintMapping[key];

}

function setUint(bytes key, uint value) isAuthorized onlyLatestVersion external {

_uintMapping[key] = value;

}

}

類似的,我們可加入所有資料型別變數,幫助commonDB應對和滿足不同的資料型別儲存需求。

相應的控制合約可修改如下:

contract FruitStoreControllerV2 is BasicAuth {

function upgradeStock(bytes32 storeName, bytes32 fruit, uint stock)

isAuthorized external {

commonDB.setUint(sha256(storeName, fruit), stock);

uint result = commonDB.getUint(sha256(storeName, fruit));

}

}

使用以上儲存的設計模式,可顯著提升合約資料儲存靈活性,保證合約可升級。

眾所周知,Solidity既不支援資料庫,使用程式碼作為儲存entity,也無法提供更改schema的靈活性。但是,透過這種KV設計,可以使儲存本身獲得強大的可擴充套件性。

總之,沒有一個策略是完美的,優秀的架構師善於權衡。智慧合約設計者需要充分了解各種方案的利弊,並基於實際情況選擇合適的設計方案。

Solidity的生命週期

與其他語言一樣,Solidity的程式碼生命週期離不開編譯、部署、執行、銷燬這四個階段。下圖整理展現了Solidity程式的完整生命週期:

經編譯後,Solidity檔案會生成位元組碼。這是一種類似jvm位元組碼的程式碼。部署時,位元組碼與構造引數會被構建成交易,這筆交易會被打包到區塊中,經由網路共識過程,最後在各區塊鏈節點上構建合約,並將合約地址返還使用者。

當用戶準備呼叫該合約上的函式時,呼叫請求同樣也會經歷交易、區塊、共識的過程,最終在各節點上由EVM虛擬機器來執行。

下面是一個示例程式,我們透過remix探索它的生命週期。

pragma solidity ^0.4.25;

contract Demo{

uint private _state;

constructor(uint state){

_state = state;

}

function set(uint state) public {

_state = state;

}

}

編譯

608060405234801561001057600080fd5b506040516020806100ed83398101806040528101908080519060200190929190505050806000819055505060a4806100496000396000f300608060405260043610603f576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b1146044575b600080fd5b348015604f57600080fd5b50606c60048036038101908080359060200190929190505050606e565b005b80600081905550505600a165627a7a723058204ed906444cc4c9aabd183c52b2d486dfc5dea9801260c337185dad20e11f811b0029

還可以得到對應的位元組碼(OpCode):

PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0x10 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PU

其中下述指令集為set函式對應的程式碼,後面會解釋set函式如何執行。

JUMPDEST DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP POP JUMP STOP

部署

編譯完後,即可在remix上對程式碼進行部署,構造引數傳入0x123:

部署成功後,可得到一條交易回執:

點開input,可以看到具體的交易輸入資料:

上面這段資料中,標黃的部分正好是前文中的合約二進位制;而標紫的部分,則對應了傳入的構造引數0x123。

這些都表明,合約部署以交易作為介質。結合區塊鏈交易知識,我們可以還原出整個部署過程:

客戶端將部署請求(合約二進位制,構造引數)作為交易的輸入資料,以此構造出一筆交易

交易經過rlp編碼,然後由傳送者進行私鑰簽名

已簽名的交易被推送到區塊鏈上的節點

區塊鏈節點驗證交易後,存入交易池

輪到該節點出塊時,打包交易構建區塊,廣播給其他節點

其他節點驗證區塊並取得共識。不同區塊鏈可能採用不同共識演算法,FISCO BCOS中採用PBFT取得共識,這要求經歷三階段提交(pre-prepare,prepare, commit)

節點執行交易,結果就是智慧合約Demo被建立,狀態欄位_state的儲存空間被分配,並被初始化為0x123

執行

根據是否帶有修飾符view,我們可將函式分為兩類:呼叫與交易。由於在編譯期就確定了呼叫不會引起合約狀態的變更,故對於這類函式呼叫,節點直接提供查詢即可,無需與其他區塊鏈節點確認。而由於交易可能引起狀態變更,故會在網路間確認。

下面將以使用者呼叫了set(0x10)為假設,看看具體的執行過程。

首先,函式set沒有配置view/pure修飾符,這意味著其可能更改合約狀態。所以這個呼叫資訊會被放入一筆交易,經由交易編碼、交易簽名、交易推送、交易池快取、打包出塊、網路共識等過程,最終被交由各節點的EVM執行。

在EVM中,由SSTORE位元組碼將引數0xa儲存到合約欄位_state中。該位元組碼先從棧上拿到狀態欄位_state的地址與新值0xa,隨後完成實際儲存。

下圖展示了執行過程:

這裡僅粗略介紹了set(0xa)是如何執行,下節將進一步展開介紹EVM的工作機制以及資料儲存機制。

銷燬

由於合約上鍊後就無法篡改,所以合約生命可持續到底層區塊鏈被徹底關停。若要手動銷燬合約,可透過位元組碼selfdestruct。銷燬合約也需要進行交易確認,在此不多作贅述。

EVM原理

在前文中,我們介紹了Solidity程式的執行原理。經過交易確認後,最終由EVM執行位元組碼。對EVM,上文只是一筆帶過,這一節將具體介紹其工作機制。

執行原理

EVM是棧式虛擬機器,其核心特徵就是所有運算元都會被儲存在棧上。下面我們將透過一段簡單的Solidity語句程式碼看看其執行原理:

uint a = 1;

uint b = 2;

uint c = a + b;

這段程式碼經過編譯後,得到的位元組碼如下:

PUSH1 0x1

PUSH1 0x2

ADD

為了讀者更好了解其概念,這裡精簡為上述3條語句,但實際的位元組碼可能更復雜,且會摻雜SWAP和DUP之類的語句。

我們可以看到,在上述程式碼中,包含兩個指令:PUSH1和ADD,它們的含義如下:

PUSH1:將資料壓入棧頂。

ADD:POP兩個棧頂元素,將它們相加,並壓回棧頂。

這裡用半動畫的方式解釋其執行過程。下圖中,sp表示棧頂指標,pc表示程式計數器。當執行完push1 0x1後,pc和sp均往下移:

類似地,執行push1 0x2後,pc和sp狀態如下:

最後,當add執行完後,棧頂的兩個運算元都被彈出作為add指令的輸入,兩者的和則會被壓入棧:

儲存探究

在開發過程中,我們常會遇到令人迷惑的memory修飾符;閱讀開原始碼時,也會看到各種直接針對記憶體進行的assembly操作。不瞭解儲存機制的開發者遇到這些情況就會一頭霧水,所以,這節將探究EVM的儲存原理。

在前文《智慧合約編寫之Solidity的基礎特性》中我們介紹過,一段Solidity程式碼,通常會涉及到區域性變數、合約狀態變數。

而這些變數的儲存方式存在差別,下面程式碼表明瞭變數與儲存方式之間的關係。

contract Demo{

//狀態儲存

uint private _state;

function set(uint state) public {

//棧儲存

uint i = 0;

//記憶體儲存

string memory str = "aaa";

}

}

棧用於儲存位元組碼指令的運算元。在Solidity中,區域性變數若是整型、定長位元組陣列等型別,就會隨著指令的執行入棧、出棧。

例如,在下面這條簡單的語句中,變數值1會被讀出,透過PUSH操作壓入棧頂:

uint i = 1;

對於這類變數,無法強行改變它們的儲存方式,如果在它們之前放置memory修飾符,編譯會報錯。

記憶體

記憶體類似java中的堆,它用於儲存"物件"。在Solidity程式設計中,如果一個區域性變數屬於變長位元組陣列、字串、結構體等型別,其通常會被memory修飾符修飾,以表明儲存在記憶體中。

本節中,我們將以字串為例,分析記憶體如何儲存這些物件。

1. 物件儲存結構

下面將用assembly語句對複雜物件的儲存方式進行分析。

assembly語句用於呼叫位元組碼操作。mload指令將被用於對這些位元組碼進行呼叫。mload(p)表示從地址p讀取32位元組的資料。開發者可將物件變數看作指標直接傳入mload。

在下面程式碼中,經過mload呼叫,data變數儲存了字串str在記憶體中的前32位元組。

string memory str = "aaa";

bytes32 data;

assembly{

data := mload(str)

}

掌握mload,即可用此分析string變數是如何儲存的。下面的程式碼將揭示字串資料的儲存方式:

function strStorage() public view returns(bytes32, bytes32){

string memory str = "你好";

bytes32 data;

bytes32 data2;

assembly{

data := mload(str)

data2 := mload(add(str, 0x20))

}

return (data, data2);

}

data變量表示str的0~31位元組,data2表示str的32~63位元組。執行strStorage函式的結果如下:

0: bytes32: 0x0000000000000000000000000000000000000000000000000000000000000006

1: bytes32: 0xe4bda0e5a5bd0000000000000000000000000000000000000000000000000000

可以看到,第一個資料字得到的值為6,正好是字串"你好"經UTF-8編碼後的位元組數。第二個資料字則儲存的是"你好"本身的UTF-8編碼。

熟練掌握了字串的儲存格式之後,我們就可以運用assembly修改、複製、拼接字串。讀者可搜尋Solidity的字串庫,瞭解如何實現string的concat。

2. 記憶體分配方式

既然記憶體用於儲存物件,就必然涉及到記憶體分配方式。

memory的分配方式非常簡單,就是順序分配。下面我們將分配兩個物件,並檢視它們的地址:

function memAlloc() public view returns(bytes32, bytes32){

string memory str = "aaa";

string memory str2 = "bbb";

bytes32 p1;

bytes32 p2;

assembly{

p1 := str

p2 := str2

}

return (p1, p2);

}

執行此函式後,返回結果將包含兩個資料字:

0: bytes32: 0x0000000000000000000000000000000000000000000000000000000000000080

1: bytes32: 0x00000000000000000000000000000000000000000000000000000000000000c0

這說明,第一個字串str1的起始地址是0x80,第二個字串str2的起始地址是0xc0,之間64位元組,正好是str1本身佔據的空間。此時的記憶體佈局如下,其中一格表示32位元組(一個數據字,EVM採用32位元組作為一個數據字,而非4位元組):

0x40~0x60:空閒指標,儲存可用地址,本例中是0x100,說明新的物件將從0x100處分配。可以用mload(0x40)獲取到新物件的分配地址。

0x80~0xc0:物件分配的起始地址。這裡分配了字串aaa

0xc0~0x100:分配了字串bbb

0x100~...:因為是順序分配,新的物件將會分配到這裡。

狀態儲存

顧名思義,狀態儲存用於儲存合約的狀態欄位。

從模型而言,儲存由多個32位元組的儲存槽構成。在前文中,我們介紹了Demo合約的set函式,裡面0x0表示的是狀態變數_state的儲存槽。所有固定長度變數會依序放到這組儲存槽中。

對於mapping和陣列,儲存會更復雜,其自身會佔據1槽,所包含資料則會按相應規則佔據其他槽,比如mapping中,資料項的儲存槽位由鍵值k、mapping自身槽位p經keccak計算得來。

從實現而言,不同的鏈可能採用不同實現,比較經典的是以太坊所採用的MPT樹。由於MPT樹效能、擴充套件性等問題,FISCO BCOS放棄了這一結構,而採用了分散式儲存,透過rocksdb或mysql來儲存狀態資料,使儲存的效能、可擴充套件性得到提高。

前置準備

進入測試前,需要完成以下步驟:鏈的搭建、控制檯安裝、智慧合約開發、智慧合約編譯與部署、利用Java等SDK開發一個應用。

詳細準備工作可參考《FISCO BCOS零基礎入門,五步輕鬆構建應用》和FISCO BCOS官方文件,此處不再贅述。FISCO BCOS官方文件地址:

https://fisco-bcos-documentation.readthedocs.io/zh_CN/latest/docs/installation.html

測試環境

FISCO BCOS提供控制檯、WeBASE-Front和SDK程式碼測試,這三種環境適用於不同測試場景:

控制檯:提供命令列互動介面,透過在控制檯建立合約和輸入呼叫、查詢指令,來進行簡單除錯。適用於非常簡單的合約。

WeBASE-Front:提供視覺化互動介面以及簡易的IDE環境。適用於業務邏輯並不複雜的合約,更推薦開發人員進行一些除錯。

SDK:例如整合Java Web3sdk,建立一個Java專案,並編寫應用和測試程式碼。適用於對智慧合約質量要求較高、要求測試案例可複用、業務邏輯複雜或要求持續整合的場景。

控制檯測試

FISCO BCOS 2.0及以上版本提供了方便易用的命令列終端、『開箱即用』的區塊鏈利器,詳情可參考《FISCO BCOS 控制檯詳解,飛一般的區塊鏈體驗》。

接下來將透過一個合約示例來講解如何運用控制檯進行測試。

首先,我們寫一個HelloWorld合約:

pragma solidity ^0.4.25;

contract HelloWorld{

string public name;

constructor() public{

name = "Hello, World!";

}

function set(string n) public{

name = n;

}

}

然後,將這個合約命名為HelloWorld.sol,並儲存到控制檯所在的contracts/solidity/路徑下。隨後,執行./start.sh 啟動控制檯並部署合約:

=============================================================================================

Welcome to FISCO BCOS console(1.0.8)!

Type 'help' or 'h' for help. Type 'quit' or 'q' to quit console.

________ ______ ______ ______ ______ _______ ______ ______ ______

| | \/ \ / \ / \ | \ / \ / \ / \

| $$$$$$$$\$$$$$| $$$$$$| $$$$$$| $$$$$$\ | $$$$$$$| $$$$$$| $$$$$$| $$$$$$\

| $$__ | $$ | $$___\$| $$ \$| $$ | $$ | $$__/ $| $$ \$| $$ | $| $$___\$$

| $$ \ | $$ \$$ \| $$ | $$ | $$ | $$ $| $$ | $$ | $$\$$ \

| $$$$$ | $$ _\$$$$$$| $$ __| $$ | $$ | $$$$$$$| $$ __| $$ | $$_\$$$$$$\

| $$ _| $$_| \__| $| $$__/ | $$__/ $$ | $$__/ $| $$__/ | $$__/ $| \__| $$

| $$ | $$ \\$$ $$\$$ $$\$$ $$ | $$ $$\$$ $$\$$ $$\$$ $$

\$$ \$$$$$$ \$$$$$$ \$$$$$$ \$$$$$$ \$$$$$$$ \$$$$$$ \$$$$$$ \$$$$$$

=============================================================================================

[group:1]> deploy HelloWorld

contract address: 0x34e95689e05255d160fb96437a11ba97bb31809f

[group:1]>

合約被成功部署後就可以開始測試了。我們先列印這個合約中name的值,然後修改為新的值,最後重新查詢name中的值。

[group:1]> call HelloWorld 0x34e95689e05255d160fb96437a11ba97bb31809f name

Hello, World!

[group:1]> call HelloWorld 0x34e95689e05255d160fb96437a11ba97bb31809f set "Hello, test!"

transaction hash: 0x72c8a95b651fb5d12c44b69624d5213d58af1509f00920757fce94d019b5eae8

[group:1]> call HelloWorld 0x34e95689e05255d160fb96437a11ba97bb31809f name

Hello, test!

[group:1]>

上述例子演示瞭如何使用控制檯部署與除錯合約。控制檯設計簡潔優雅,使用體驗如絲般順滑。不過,在處理一些複雜場景,例如需要切換外部賬戶,或透過視覺化介面進行操作時,WeBASE-Front當仁不讓地扛起大旗。

WeBASE-Front測試

WeBASE-Front為開發者提供了執行核心資訊的視覺化操作、Solidity開發的IDE環境以及私鑰管理功能,讓大家更便捷地開啟區塊鏈之旅。

關於WeBASE-Front的介紹可參考《WeBASE節點前置元件功能解析》與《安裝部署說明》。《安裝部署說明》地址:

https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Install/developer.html#

接下來,我們演示一個需要切換外部賬戶的測試案例,以下是合約程式碼:

pragma solidity ^0.4.25;

contract BasicAuth {

address public _owner;

constructor() public {

_owner = msg.sender;

}

function setOwner(address owner)

public

onlyOwner

{

_owner = owner;

}

modifier onlyOwner() {

require(msg.sender == _owner, "BasicAuth: only owner is authorized.");

_;

}

}

在這個示例中,合約owner被自動賦值為合約部署者。修飾器onlyOwner決定了setOwner函式只能由_owner使用者來發起。

在合約管理-合約IDE中,建立test資料夾,並複製合約程式碼:

這時,選擇部署合約,會彈出使用者地址視窗選項,選擇user1。

合約部署成功後,會展示合約地址、合約名、abi、合約binary等系列資訊:

現在,我們來測試setOwner()函式。如上所述,本合約的_owner是user1,透過切換使用者user2來呼叫這個函式,預期結果是呼叫失敗。我們選擇setOwner方法,選擇私鑰地址為user2:

正如預期,這個函式的呼叫失敗了:

使用WeBASE-Front測試最大短板在於測試案例無法複用。倘若合約非常複雜,那所有測試案例都得靠人工一遍遍輸入,操作原始低效。

SDK測試

在系統測試中,需要遵循經典的AIR實踐原則:

Automatic:測試應該被全自動執行,這也是實現持續整合的前提。

Independent:測試案例彼此保持獨立,不存在相互依賴和呼叫。

Repeatable:測試案例必須可被複用。可以跨越軟硬體環境,被重複執行。

要滿足以上甚至更多測試實踐原則,使用控制檯或WeBASE-Front的方式就顯得有些力不從心,而整合SDK並編寫測試程式碼的方式更值得推薦。

這種方式雖然前期花費時間較長、成本較高;但測試後期能大大降低重複的工作量,顯著提升整體測試效率。

這也符合IT公司現行的軟體測試實踐規範。一般這部分程式碼由公司開發測試人員或質量保證(QA)工程師編寫。但在很多公司中,這部分程式碼由開發人員編寫。良好的測試程式碼,能提升程式碼質量,降低程式碼重構難度,提高開發效率。

FISCO BCOS提供了多語言SDK,如Java、Python、Node.js等,其中最為成熟與常用的是Java版Web3SDK。《手把手教你在Eclipse中使用JavaSDK》詳細介紹了在Eclipse建立新工程以及將已經提供的示例專案匯入Eclipse。

在Java開發實踐中,Springboot使用更為深入人心,FISCO BCOS也提供了相應使用案例,相關配置文件可以參考:《Spring Boot專案配置》,透過spring-boot-starter開發者可快速下載示例工程,並匯入偏好的IDE中。

配置工程鏈基本環境後,下面將以上述spring-boot-starter工程為例,介紹具體測試步驟和要點。

編寫合約:HelloWorld合約。

編譯智慧合約,將其轉為Java檔案,從上述WeBASE-Front中,也能編譯並匯出Java檔案。

將編譯生成的Java檔案匯入工程中,如HelloWorld.java。

基於上述檔案,呼叫合約功能和介面,編寫相關測試案例,例如:ContractTest。

基於Spring提供的gradle外掛,我們可以透過"./gradlew test"命令來執行所有測試案例。

如果需要持續整合,可以在配置和初始化FISCO BCOS後,將步驟5命令加入自動化指令碼中。

接下來,我們將具體分析核心測試程式碼ContractTest:

@Test

public void deployAndCallHelloWorld() throws Exception {

// deploy contract

HelloWorld helloWorld = HelloWorld

.deploy(web3j, credentials, new StaticGasProvider(GasConstants.GAS_PRICE, GasConstants.GAS_LIMIT))

.send();

Assert.assertNotNull(helloWorld);

// call set function

helloWorld.set("Hello, World!").send();

// call get function

String result = helloWorld.get().send();

Assert.assertTrue("Hello, World!".equals(result));

}

第4行中,HelloWorld合約被部署。為符合獨立性原則,建議在每個測試案例中部署獨立的合約,以避免測試案例執行順序對正常測試造成干擾。需要模擬合約依賴性的情況除外。

第9行和第11行,分別呼叫了set和get。為符合可重複性原則,該測試案例必須設計成冪等,即在任意的軟硬體環境下,測試案例預期結果都是一致的。

第7行和第12行,使用了Junit框架提供的斷言方法,用來判斷智慧合約執行結果是否符合預期。

值得一提的是,在Java SDK中,任意交易上鍊後,會獲得一個回執TransactionReceipt物件,該物件中包含了返回狀態和錯誤資訊(如果交易成功,則message為null),可透過這個狀態判斷交易是否正常,例如:

TransactionReceipt tr = helloWorld.set("Hello, World!").send();

Assert.assertEquals(tr.getMessage(), "0x0", tr.getStatus());

https://docs.spring.io/spring-boot/docs/2.2.6.RELEASE/reference/html/spring-boot-features.html#boot-features-testing

測試型別

如傳統軟體,智慧合約的測試同樣可分為功能測試、非功能測試、安全性測試和迴歸測試,下面將對這些測試作逐一介紹。

功能測試

功能測試包括但不限於單元測試、整合測試、冒煙測試、使用者驗收測試。除使用者驗收測試外,其他測試都可透過開發或測試人員編寫程式碼實現。

智慧合約測試重要目的之一是檢測合約程式碼正確性,在預定輸入值前提下,檢查輸出值是否符合預期。

上文我們介紹了控制檯、WeBASE-Front和SDK三種測試環境。

在一些邏輯複雜的智慧合約中,測試難點之一在於構造測試案例。在這種場景下,使用智慧合約可以更好地模擬和構造測試資料,直接使用Solidity編寫智慧合約更為原生、友好。

最後,測試並不遊離於智慧合約開發之外,而是其中重要的一環,測試也需遵循依賴關係原則,即開發者在進行開發時需要考慮智慧合約的『可測試性』。

例如,假設測試程式碼完全使用SDK編寫,那智慧合約的修改就可能導致測試程式碼需要做出對應變更,這會對測試效果造成影響,提升測試成本。基於軟體設計『不可依賴多變部分』的原則,可測試性同樣不能依賴於多變部分。

為了解決上述問題,我們在智慧合約層引入測試程式碼。這些程式碼僅被設計為測試元件,不會發布到線上環境,以此將測試案例改動影響解耦並封裝到智慧合約層。測試合約作為獨立元件,支援開發和測試環節。

測試元件中首先可以抽象和定義一些測試的工具合約,例如:

pragma solidity 0.4.25;

library LibAssert {

event LogTest(bool indexed result, string message);

function equal(uint8 a, uint8 b, string message) internal returns (bool result) {

result = (a == b);

log(result, message);

}

function notEqual(uint8 a, uint8 b, string message) internal returns (bool result) {

result = (a != b);

log(result, message);

}

function log(bool result, string message) internal {

if(result)

emit LogTest(true, "");

else

emit LogTest(false, message);

}

}

這是一個最簡單的測試合約庫,提供了equal和notEqual的方法來判斷uint8變數,開發者可基於此工具擴充套件自己的測試工具合約。

其次,依託於工具合約,我們可以編寫獨立的測試合約案例。

下面將以上述Uint8MathTest為例,演示如何使用此工具合約,首先,我們定義一個TestDemo合約:

pragma solidity ^0.4.25;

import "./Uint8MathTest.sol";

import "./LibAssert.sol";

contract TestDemo {

using LibAssert for uint8;

function testAdd() external returns (bool result) {

uint8 a = 1;

uint8 b = 1;

Uint8MathTest math = new Uint8MathTest();

uint8 c = math.add(a,b);

uint8 expectedValue = 2;

result = expectedValue.equal(c, "Not equal");

}

}

然後,在控制檯中執行這個測試案例:

[group:1]> deploy TestDemo

contract address: 0xd931b41c70d2ff7b54eb9b2453072f9b1a4dc05c

[group:1]> call TestDemo 0xd931b41c70d2ff7b54eb9b2453072f9b1a4dc05c testAdd

transaction hash: 0xe569e5d8eae1b949a0ffe27a60f0b4c8bd839f108648f9b18879833c11e94ee4

---------------------------------------------------------------------------------------------

Output

function: testAdd()

return type: (bool)

return value: (true)

---------------------------------------------------------------------------------------------

Event logs

---------------------------------------------------------------------------------------------

除了依賴自定義的智慧合約測試程式碼,也可以用智慧合約本身來編寫測試案例。在SDK層,我們只需實現TestDemo中的測試函式程式碼。即使未來測試邏輯改動,也無需改動SDK程式碼,從而保證了測試程式碼的健壯性。

合約測試程式碼元件需貫徹智慧合約在整體設計中所遵循的設計原則。

非功能測試

非功能測試主要包括效能測試、容量測試、可用性測試等。由於智慧合約運行於FISCO BCOS的底層節點,容量測試和可用性測試更多與底層節點相關,因此,對使用者而言,非功能測試的重點就在效能測試上。

雖然,我們可以使用系列通用效能測試工具對智慧合約進行測試,但是,實際壓測過程中會存在一些痛點,例如:

針對具體合約場景測試,存在大量且重複的測試程式碼,費時費力;

效能指標缺乏統一度量,無法橫向對比;

展示結果不夠直觀。

為了解決以上痛點,FISCO BCOS適配了區塊鏈測試專業工具Caliper,讓使用者可以優雅地進行效能測試。更多細節和內容請參考:《效能測試工具Caliper在FISCO BCOS平臺中的實踐》。

安全性測試

智慧合約上線前需要進行嚴格的安全性測試。

安全性測試的方式包括:公開智慧合約併發布懸賞、聘請專門的智慧合約安全機構對合約進行檢測和評估、使用智慧合約專用的工具進行審計等。

大家可以視自身合約的重要性及邏輯複雜程度選擇對應的安全性測試級別。對於個人開發者或非重大業務的智慧合約,選擇免費的智慧合約工具檢測即可。

下面以VS Code為例,演示如何使用智慧合約安全外掛對合約進行安全性檢測。開啟VS Code,在其外掛庫中搜索Beosin-VaaS: ETH,選擇安裝。

隨後,開啟智慧合約檔案,右鍵選擇Beosin-VaaS:ETH選項,在彈出的視窗中選擇當前合約程式碼版本。安裝完成後,會自動彈出如下介面:

start compile!

compile over!

start deploy!

deploy over!

start execute function name: set(string)

execute function name : set(string) end!

No problem was found in the contract

當我們對Uint8MathTest合約進行檢測時,會發現報錯:

####### Compile Problem #######

file: /Users/maojiayu/Downloads/Uint8MathTest.sol

title: Compile Problem

description: Function state mutability can be restricted to pure

code: function add(uint8 a, uint8 b) public returns (uint8 c) {

warningType: Info

line: 5

透過報錯資訊,我們可以發現該函式既沒讀取也沒使用區塊鏈上的狀態,符合pure修飾符的使用條件,建議使用『pure』關鍵字來修飾。

安全性測試是合約測試過程中不可或缺的一環,需高度重視。在嚴格的工程實踐中,合約上生產前必須由相關的測試負責人出具安全性檢測報告。

此外,開發人員可以收集和總結Solidity安全程式設計規則和漏洞風險提示,並動態更新維護。在交付測試前,開發人員可組織程式碼審查和走查,依據總結,集中、逐項進行風險排查。

安全性測試是檢測和評價智慧合約是否安全的必備環節,但不能將合約安全性完全寄望於測試。更重要的是,在設計、開發和測試的各個階段,都需要使用者時刻保持安全意識,建立和培養安全編碼習慣。合約釋出上線後,也不能放鬆警惕,時刻關注業界最新的安全警告,並定期、動態對所有程式碼進行檢測和掃描。

迴歸測試

迴歸測試通常包括執行持續整合的自動化測試案例以及合約上線前的人工測試。

上述SDK以及智慧合約測試案例可有效覆蓋測試案例所執行的路徑和範圍。自動化的迴歸測試有助於快速檢測並發現問題,節省大量重複人力,保證合約經歷系列修改和測試後,不偏離主幹功能。同樣,開發者可使用Jenkins、Travis等工具進行構建和測試。

除了執行個別自動化測試無法執行的案例外,人工測試更多地是確認之前修改的程式碼是否符合預期,智慧合約的執行是否符合當初設計。此外,在合約最終釋出前,人工測試還起到稽核檢查作用,故而一定要認真對待。

測試要點

無論何種型別的系統測試,均需特別注意以下測試要點。

注意邊界值的測試,例如數字溢位、特殊取值、迴圈邊界值等。

注意檢查智慧合約的實現是否符合預期,其執行邏輯和流程是否與設計一致。

除了正常流程外,還需要模擬和測試在各種異常環境,甚至是極端環境下,智慧合約運作是否正常,能否達到預期的處理結果。

圍繞智慧合約不變的業務邏輯,忽略變化值,進行對應測試。

邊界值

邊界值測試是一種常見的軟體測試方法。據統計,絕大多數的bug發生在輸入或輸出範圍的邊界上,而非其內部。

我們來演示一個典型的數值溢位測試案例。如下簡單的合約,實現了兩個uint8數值加法,並返回了結果:

pragma solidity 0.4.25;

contract Uint8MathTest {

function add(uint8 a, uint8 b) public returns (uint8 c) {

c = a + b;

}

}

這時,我們需要設計一個邊界值的測試案例。已知uint8的取值範圍在[0,28)即[0,256)之間,以此設計一個溢位的測試案例:(a=255, b=1),測試結果如下:

[group:1]> deploy Uint8MathTest

contract address: 0xd8a765995c58eb8da103bdcc2c033c0acb81e373

[group:1]> call Uint8MathTest 0xd8a765995c58eb8da103bdcc2c033c0acb81e373 add 255 1

transaction hash: 0x0583185839bc52870ac57cdfd00c0c18840c2674d718b4cc3cb7bc1ef4c173e0

---------------------------------------------------------------------------------------------

Output

function: add(uint8,uint8)

return type: (uint8)

return value: (0)

---------------------------------------------------------------------------------------------

可以看到最後結果變為0,而不是預期的256,顯然,這是因為結果發生了溢位。

當然,在使用邊界值測試方法取值時,並非只取有效邊界值,還應包含無效邊界值。邊界值測試可幫助查出更多錯誤和缺陷。

是否符合預期

在建築公司交付房屋後,開發商會依據設計標準檢驗房屋建造是否合格。同樣,除了檢查合約執行結果是否正確外,在測試中還需檢查合約互動、操作、資料流向、效能表現等各方面實現是否符合設計。

舉個例子,在金融業場景中,我們會使用Solidity開發具體鏈上業務邏輯。

其中,不可避免與金額打交道,就需要測試對應資產規模是否符合設計標準。在需求和設計階段,我們會預估執行在鏈上的資產規模並作設計要求,例如要求某個供應鏈金融區塊鏈業務系統能在千億資產規模下正常執行。

假設在智慧合約中,定義金額的欄位是uint32,可以計算其最大能表示的值為232即不到43億。

雖然在智慧合約釋出上線後大多數時間內,該問題不會造成任何影響;但是,一旦業務快速增長,當資產規模超過43億後,就會出現嚴重故障。

這類問題非常隱蔽,因此,需要更加全面、仔細的測試來提早發現和定位。

異常流程

在實際業務場景中,即便經過系列測試保證智慧合約執行符合絕大多數場景需求,也可能因為疏忽或遺漏,導致在特定異常流程下,出現問題,在智慧合約釋出後造成了重大影響。

故而,開發者需要特別重視在測試範圍和內容中對異常流程的搜尋和覆蓋,儘可能測試覆蓋到所有的異常流程。

變與不變

使用Solidity測試與其他語言的顯著不同還在於合約中的很多交易和值無法復現。正如『人的一生無法兩次踏入同一條河流』,隨著私鑰、證書、環境等改變,其交易Hash值、區塊Hash、合約地址等也會隨之不同,這也給測試帶來一些客觀困難。

另一方面,Solidity對於一致性有著嚴苛要求,例如對EVM執行指令語義的一致性、對外部隨機變數的『排斥』等。掌握合約測試的關鍵在於抓住其中不變的方面。

測試技巧

作為區塊鏈上專用程式語言,相比其他高階語言,Solidity有非常多限制。例如,缺乏成熟的測試工具支援,無法在執行時進行debug以及檢視EVM虛擬機器內的狀態與資料。

同時,Solidity缺乏獨立的資料抽象層,導致無法像傳統應用那樣直接透過連線資料庫來檢視合約內詳細的變數狀態,只能透過在合約內增加查詢呼叫來『曲線救國』。

當然,我們也可以採取一些技巧,儘可能規避以上問題。

如何展示合約更多內部變數?

編寫Solidity程式碼與其他高階語言最大差別是無法進行debug。這個問題解決辦法之一是列印和展示更多內部變數。

問題場景:

還是上述的HelloWorld合約,現在我們無法在set函式執行時獲取原有的name值,只能透過name()函式來查詢。有沒有辦法在set函式執行時,記錄和打印出name原來的值呢?

解決方案:

定義一個event,在set函式內部進行呼叫。例如:

pragma solidity ^0.4.25;

contract HelloWorld{

string public name;

event LogSet(string oldValue, string newValue, address sender);

constructor() public{

name = "Hello, World!";

}

function set(string n) public{

emit LogSet(n, name, msg.sender);

name = n;

}

}

然後,在控制檯中執行,可以看到剛才定義的LogSet事件被打印出來了。我們在事件中還定義列印了msg.sender的地址,如下圖所示,對應地址也被打印出來。

Event相當於提供了log日誌的簡單機制,當出現無法解決且需要展示合約內部狀態的情況,Event提供了合適的方法和機制。

如何獲得鏈上全量資料模型?

問題場景:

都說Solidity是圖靈完備的語言,EVM是設計簡潔的狀態機,如果想要獲取狀態機內部每個狀態和交易的『世界狀態』蛛絲馬跡,有什麼好的解決方案?

解決方案:

Solidity缺乏獨立的可對外訪問的資料層,無法直接獲取每筆交易或每個狀態的詳情。不過,我們可以透過WeBASE資料匯出元件來匯出所有鏈上資料。

WeBASE-Collect-Bee可以匯出區塊鏈上的基礎資料,如當前塊高、交易總量等。

如果正確配置了FISCO BCOS上執行的所有合約,WeBASE-Collect-Bee可以匯出區塊鏈上合約的業務資料,包括event、建構函式、合約地址、執行函式的資訊等。

資料匯出元件WeBASE-Collect-Bee的目的在於降低獲取區塊鏈資料的開發門檻,提升研發效率。研發人員幾乎無需編寫任何程式碼,只進行簡單配置,就可以把資料匯出到Mysql資料庫。

還是以上面的HelloWorld為例,參考安裝文件:WeBASE-Collect-Bee快速部署。

安裝過程略過不表,匯出以後會自動在資料庫中生成如下的表:

資料庫表的功能如下:

account_info: 記錄了所有在鏈上部署的合約資訊,包括了合約名、合約地址、塊高、上鍊時間等。

blcok_detail_info: 記錄了每個區塊的資訊,包括區塊hash、交易數量、塊高、上鍊時間等。

block_task_pool:資料匯出的任務表,記錄了任務詳情和狀態資訊。

block_tx_detail_info:記錄了每筆交易的資訊,包括了交易的合約名稱、函式名稱、交易傳送者地址、交易接收者地址、交易hash等。

hello_world_log_set_event:資料匯出會自動生成每個event的資料庫表,命名規則為合約名+事件名。上面例子中定義的event所自動生成的表,包括了event中定義的所有變數和交易資訊等。

hello_world_set_method:資料匯出會自動生成不同transaction的資料庫表,命名規則為合約名+函式名。上面例子中定義的set函式表,包含了函式的入參、返回碼和交易hash等。

hello_world_log_set_event顯示了所有HelloWorld合約中,LogSetEvent事件記錄的資訊。

hello_world_set_method顯示了歷史呼叫的所有函式資訊。

有了上述的資料庫匯出狀態與資訊,使用區塊鏈瞬間變得容易,測試也更加得心應手、如虎添翼。資料庫會如實地匯出和記錄區塊鏈上所有操作,讓一切鏈上資料都盡在掌握中!

8
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 事務註解導致MyBatis-Plus多資料來源失效