智慧合約編寫之Solidity的程式設計攻略

買賣虛擬貨幣
前  言作為一名搬磚多年的資深碼農,剛開始接觸Solidity便感覺無從下手:昂貴的計算和儲存資源、簡陋的語法特性、令人抓狂的debug體驗、近乎貧瘠的類庫支援、一言不合就插入彙編語句……讓人不禁懷疑,這都已經過了9012年了,居然還有這種反人類的語言?對於習慣使用各類日益“傻瓜化”的類庫和自動化高階框架的碼農而言,學習Solidity的過程就是一場一言難盡的勸退之旅。但隨著對區塊鏈底層技術的深入學習,大家會慢慢理解作為執行在“The World Machine”上的Solidity語言,必須要嚴格遵循的設計原則以及權衡後必須付出的代價。正如駭客帝國中那句著名的slogan:“Welcome to the dessert of the real”,在惡劣艱苦的環境面前,最重要的是學會如何適應環境、儲存自身並快速進化。本文總結了一些Solidity程式設計的攻略,期待各位讀者不吝分享交流,達到拋磚引玉之效。
上鍊的原則“如無必要,勿增實體”。基於區塊鏈技術及智慧合約發展現狀,資料的上鍊需遵循以下原則:· 需要分散式協作的重要資料才上鍊,非必需資料不上鍊;· 敏感資料脫敏或加密後上鏈(視資料保密程度選擇符合隱私保護安全等級要求的加密演算法); · 鏈上驗證,鏈下授權。
在使用區塊鏈時,開發者不需要將所有業務和資料都放到鏈上。相反,“好鋼用在刀刃上”,智慧合約更適合被用在分散式協作的業務場景中。精簡函式變數如果在智慧合約中定義了複雜的邏輯,特別是合約內定義了複雜的函式入參、變數和返回值,就會在編譯的時候碰到以下錯誤: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的&l

免責聲明:

  1. 本文版權歸原作者所有,僅代表作者本人觀點,不代表鏈報觀點或立場。
  2. 如發現文章、圖片等侵權行爲,侵權責任將由作者本人承擔。
  3. 鏈報僅提供相關項目信息,不構成任何投資建議

推荐阅读

;