智慧合約編寫之 Solidity的設計模式

買賣虛擬貨幣
前  言隨著區塊鏈技術發展,越來越多的企業與個人開始將區塊鏈與自身業務相結合。區塊鏈所具有的獨特優勢,例如,資料公開透明、不可篡改,可以為業務帶來便利。但與此同時,也存在一些隱患。資料的公開透明,意味著任何人都可以讀取;不可篡改,意味著資訊一旦上鍊就無法刪除,甚至合約程式碼都無法被更改。除此之外,合約的公開性、回撥機制,每一個特點都可被利用,作為攻擊手法,稍有不慎,輕則合約形同虛設,重則要面臨企業機密洩露的風險。所以,在業務合約上鍊前,需要預先對合約的安全性、可維護性等方面作充分考慮。幸運的是,透過近些年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,通常要面對以下問題:

1. 合約上已有的業務資料怎麼處理?
2. 怎麼儘可能減少升級影響範圍,讓其餘功能不受影響?
3. 依賴它的其他合約該怎麼辦?

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

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)); 
        _;
    }

免責聲明:

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

推荐阅读

;