隨着區塊鏈技術發展,愈來愈多的企業與我的開始將區塊鏈與自身業務相結合。git
區塊鏈所具備的獨特優點,例如,數據公開透明、不可篡改,能夠爲業務帶來便利。但與此同時,也存在一些隱患。數據的公開透明,意味着任何人均可以讀取;不可篡改,意味着信息一旦上鍊就沒法刪除,甚至合約代碼都沒法被更改。程序員
除此以外,合約的公開性、回調機制,每個特色均可被利用,做爲攻擊手法,稍有不慎,輕則合約形同虛設,重則要面臨企業機密泄露的風險。因此,在業務合約上鍊前,須要預先對合約的安全性、可維護性等方面做充分考慮。github
幸運的是,經過近些年Solidity語言的大量實踐,開發者們不斷提煉和總結,已經造成了一些"設計模式",來指導應對平常開發常見的問題。編程
2019年,IEEE收錄了維也納大學一篇題爲《Design Patterns For Smart Contracts In the Ethereum Ecosystem》的論文。這篇論文分析了那些火熱的Solidity開源項目,結合以往的研究成果,整理出了18種設計模式。設計模式
這些設計模式涵蓋了安全性、可維護性、生命週期管理、鑑權等多個方面。安全
接下來,本文將從這18種設計模式中選擇最爲通用常見的進行介紹,這些設計模式在實際開發經歷中獲得了大量檢驗。oracle
智能合約編寫,首要考慮的就是安全性問題。app
在區塊鏈世界中,惡意代碼數不勝數。若是你的合約包含了跨合約調用,就要特別小心,要確認外部調用是否可信,尤爲當其邏輯不爲你所掌控的時候。模塊化
若是缺少防人之心,那些「居心叵測」的外部代碼就可能將你的合約破壞殆盡。好比,外部調用可經過惡意回調,使代碼被反覆執行,從而破壞合約狀態,這種攻擊手法就是著名的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已完成合約自身狀態全部相關工做,使得狀態完整、邏輯自洽,這樣外部調用就沒法利用不完整的狀態進行攻擊了。
回顧前文的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模式也是解決重放攻擊的有效方式。它經過提供一個簡單的修飾符來防止函數被遞歸調用:
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,修飾符的第一行代碼會拋出錯誤。
在區塊鏈中,合約一旦部署,就沒法更改。當合約出現了bug,一般要面對如下問題:
合約上已有的業務數據怎麼處理?
怎麼儘量減小升級影響範圍,讓其他功能不受影響?
依賴它的其餘合約該怎麼辦?
回顧面向對象編程,其核心思想是將變化的事物和不變的事物相分離,以阻隔變化在系統中的傳播。因此,設計良好的代碼一般都組織得高度模塊化、高內聚低耦合。利用這個經典的思想可解決上面的問題。
瞭解該設計模式以前,先看看下面這個合約代碼:
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模式運用單一職責原則解決上述問題,提倡將合約子功能放到子合約裏,每一個子合約(也稱爲衛星合約)只對應一個功能。當某個子功能須要修改,只要建立新的子合約,並將其地址更新到主合約裏便可,其他功能不受影響。
舉個簡單的例子,下面這個合約的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; } }
在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 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 = //...; }
在默認狀況下,一個合約的生命週期近乎無限——除非賴以生存的區塊鏈被消滅。但不少時候,用戶但願縮短合約的生命週期。這一節將介紹兩個簡單模式提早終結合約生命。
字節碼中有一個selfdestruct指令,用於銷燬合約。因此只須要暴露出自毀接口便可:
contract Mortal{ //自毀 function destroy() public{ selfdestruct(msg.sender); } }
若是你但願一個合約在指按期限後中止服務,而不須要人工介入,可使用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層。
前文中有許多管理性接口,這些接口若是任何人均可調用,會形成嚴重後果,例如上文中的自毀函數,假設任何人都能訪問,其嚴重性不言而喻。因此,一套保證只有特定帳戶可以訪問的權限控制設計模式顯得尤其重要。
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修飾符就會先運行並檢測調用者是否與合約擁有者一致,從而將無受權的調用攔截在外。
這類模式通常針對具體場景使用,這節將主要介紹基於隱私的編碼模式和與鏈外數據交互的設計模式。
鏈上數據都是公開透明的,一旦某些隱私數據上鍊,任何人均可看到,而且再也沒法撤回。
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合約:
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 } }
本文的介紹涵蓋了安全性、可維護性等多種設計模式,其中,有些偏原則性,如Security和Maintaince設計模式;有些是偏實踐,例如Authrization,Action And Control。
這些設計模式,尤爲實踐類,並不能涵蓋全部場景。隨着對實際業務的深刻探索,會遇到愈來愈多的特定場景與問題,開發者可對這些模式提煉、昇華,以沉澱出針對某類問題的設計模式。
上述設計模式是程序員的有力武器,掌握它們可應對許多已知場景,但更應掌握提煉設計模式的方法,這樣才能從容應對未知領域,這個過程離不開對業務的深刻探索,對軟件工程原則的深刻理解。
FISCO BCOS的代碼徹底開源且免費
下載地址****↓↓↓