1. 程式人生 > 實用技巧 >萬字好文:智慧合約編寫之Solidity的程式設計攻略(轉載)

萬字好文:智慧合約編寫之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)publiconlyOwner{ _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)publiccanSetOwner{        _owner = owner;    }function setBakOwner(address owner)publiccanSetOwner{        _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)publicauth{ _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":188873struct 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":188937struct 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設計,可以使儲存本身獲得強大的可擴充套件性。

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