智能合約編寫之Solidity的編程攻略 | FISCO BCOS超話區塊鏈專場(篇5)

前  言

做爲一名搬磚多年的資深碼農,剛開始接觸Solidity便感受無從下手:昂貴的計算和存儲資源、簡陋的語法特性、使人抓狂的debug體驗、近乎貧瘠的類庫支持、一言不合就插入彙編語句……讓人不由懷疑,這都已通過了9012年了,竟然還有這種反人類的語言?html

對於習慣使用各種日益「傻瓜化」的類庫和自動化高級框架的碼農而言,學習Solidity的過程就是一場一言難盡的勸退之旅。git

但隨着對區塊鏈底層技術的深刻學習,你們會慢慢理解做爲運行在「The World Machine」上的Solidity語言,必需要嚴格遵循的設計原則以及權衡後必須付出的代價。github

正如黑客帝國中那句著名的slogan:「Welcome to the dessert of the real」,在惡劣艱苦的環境面前,最重要的是學會如何適應環境、保存自身並快速進化。web

本文總結了一些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;
    }
}

如上所述,咱們添加了函數執行前的參數檢查的函數修飾器。同理,經過使用函數執行前和函數執行後檢查的函數修飾器,能夠保證智能合約更加安全、清晰。智能合約的編寫須要設置嚴格的前置和後置函數檢查,來保證其安全性。

嚴控函數的執行權限

(https://mmbiz.qpic.cn/mmbiz_png/XPgc9ZEIOSRvnrxWMCudaaaariabQM3VU7Qp2e9eP3axhvmXdyH01icDWYdeTMHiaUKbZj2S9MRucx3icZddNFrWGQ/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)**

若是說智能合約的參數和行爲檢測提供了靜態的合約安全措施,那麼合約權限控制的模式則提供了動態訪問行爲的控制。

因爲智能合約是發佈到區塊鏈上,全部數據和函數對全部參與者都是公開透明的,任一節點參與者均可發起交易,沒法保證合約的隱私。所以,合約發佈者必須對函數設計嚴格的訪問限制機制。

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];
    }

但在具體業務場景中,只須要控制合約訪問者便可,經過刪除相應代碼,進一步簡化使用邏輯。這樣一來,對應合約的binary code長度會大大縮小。
 

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中,一旦合約部署發佈後,其代碼就沒法被修改,只能經過發佈新合約去改動代碼。假如數據存儲在老合約,就會出現所謂的「孤兒數據」問題,新合約將丟失以前運行的歷史業務數據。

這種狀況,開發者能夠考慮將老合約數據遷移到新合約中,但此操做至少存在兩個問題:

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

  2. 牽一髮而動全身,會引入額外的遷移數據邏輯,增長合約複雜度。

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

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

  • 數據合約:在合約中保存數據,並提供數據的操做接口。

  • 管理合約:設置控制權限,保證只有控制合約纔有權限修改數據合約。

  • 控制合約:真正須要對數據發起操做的合約。

具體的代碼示例以下:

數據合約:

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的世界裏翱翔暢遊,並不斷書寫輝煌的故事和傳說:)

特別感謝:文中羚羊峽拍攝者Jeniffer
 

FISCO BCOS的代碼徹底開源且免費

下載地址****↓↓↓

https://github.com/FISCO-BCOS/FISCO-BCOS

相關文章
相關標籤/搜索