以太坊實現智慧合約升級的三種代理模式

買賣虛擬貨幣
以太坊的最大優勢之一是其公共賬本內交易記錄的不可篡改性,這些交易包括Token的轉移,合約的部署以及合約交易。以太坊網路上的任何節點都可以驗證每筆交易的有效性和狀態,從而使以太坊成為一個非常強大的去中心化系統。但最大的缺點是,智慧合約一旦部署後,則無法更改合約原始碼。中心化應用程式(例如Facebook或Airbnb)的開發人員會經常對程式進行更新,修復bug或引入新功能。而這種方式在以太坊上是不可能做到的。還記得著名的Parity Wallet 事件,駭客盜取了150000個ETH,在這次的攻擊中,Parity multisig錢包中一個合約的漏洞被駭客利用,盜取了錢包中的資金。在駭客攻擊過程中,我們唯一能做的就是利用相同的漏洞,比駭客更快速的將錢包中的資金進行轉移,並在事後歸還給所有者。如果有一種方法可以在智慧合約部署後,更新原始碼……引入代理模式雖然無法更新已部署的智慧合約程式碼,但是可以透過設定一個代理合約架構,進而部署新的合約,以實現合約升級的目的。

代理模式使得所有訊息呼叫都透過代理合約,代理合約會將呼叫請求重定向到最新部署的合約中。如要升級時,將升級後新合約地址更新到代理合約中即可。

Zeppelin在實現zeppelin_os[1]的過程中一直在研究幾種代理模式[2]。探索了三種代理模式:

1.繼承儲存2.永久儲存3.非結構化儲存

這三種模式底層都依賴delegatecalls來實現。雖然Solidity提供了delegatecall方法,但它僅在呼叫成功後返回true / false,無法管理返回的資料。

在深入研究之前,需要先理解兩個重要的概念:

•當呼叫的方法在合約中不存在時,合約會呼叫fallback函式。可以編寫fallback函式的邏輯處理這種情況。代理合約使用自定義的fallback函式將呼叫請求重定向到其他合同中。•每當合約A將呼叫代理到另一個合同B時,它都會在合約A的上下文中執行合約B的程式碼。這意味著將保留msg.value和msg.sender值,並且每次儲存修改都會影響合約A。

所有代理模式都繼承了Zeppelin’s Proxy contract[3],該合約實現了自己的代理呼叫函式,該函式返回撥用邏輯合約的值。如果您打算使用Zeppelin的代理合約程式碼,需要詳細瞭解代理合約程式碼。讓我們探索它是如何工作的,並瞭解它用於實現代理的彙編操作碼。(參考Solidity的Assembly文件[4]以獲取更多資訊)

[assembly {
    let ptr := mload(0x40)
    calldatacopy(ptr, 0, calldatasize)
    let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
    let size := returndatasize
    returndatacopy(ptr, 0, size)

    switch result
    case 0 { revert(ptr, size) }
    default { return(ptr, size) }
 }

為了將呼叫請求代理到給另一個合約中,我們必須將代理合約收到的msg.data傳遞給目標合約。由於msg.data的型別為bytes[5],大小是不固定的,資料大小儲存在msg.data的第一個字長(32個位元組)中。如果我們只想提取實際資料,則需要跳過前32位元組,從msg.data的0x20(32個位元組)位置開始。這裡,我們將利用兩個操作碼來執行該操作。使用calldatasize獲得msg.data的大小,使用calldatacopy將其複製到ptr變數中。

注意如何初始化ptr 變數。在Solidity中,記憶體插槽0x40位置是比較特殊的,它包含了下一個可用的空閒記憶體指標的值。每次將變數直接儲存到記憶體時,都應透過查詢0x40位置的值,來確定變數儲存在記憶體的位置。現在,可以使用calldatacopy用來將大小為calldatasize 的資料複製到ptr中。

let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)

接下來看一下彙編模組中delegatecall操作碼:

let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)

引數:

•gas 我們傳遞執行合約所需要燃料•_impl 所請求的目標合約地址•ptr 請求資料在記憶體中的起始位置•calldatasize 請求資料的大小。•0用於表示目標合約的返回值。這是未使用的,因為此時我們尚不知道返回資料的大小,因此無法將其分配給變數。之後我們可以使用returndata操作碼訪問此資訊•0表示目標合約返回值的大小。這是未使用的,因為在呼叫目標合約之前,我們是無法知道返回值的大小。之後我們可以透過returndatasize操作碼來獲得該值

下一行,使用returndatasize操作碼獲取返回值的大小

let size := returndatasize

然後,我們使用returndatacopy操作碼將返回的資料複製到ptr變數中。

returndatacopy(ptr, 0, size)

最後,switch語句返回的資料或者丟擲異常。

至此,我們現在有一種方法可以從目標合約中獲取到返回結果。

現在我們瞭解了代理合約的工作原理,讓我們看一下Zeppelin提出的三種模式:使用繼承儲存,非結構化儲存和永久儲存來實現合約的可升級。

這三種模式都用來解決同一個難題:如何確保目標合約不會覆蓋代理合約中用於升級的狀態變數。

所有代理模式的主要關注點是如何處理儲存分配。請記住,由於我們將一個合約用於儲存,而將另一個合約用於邏輯處理,因此任何一個合約都可能覆蓋已使用的儲存插槽。這意味著,如果代理合約具有狀態變數以跟蹤某個儲存插槽中的最新邏輯合約地址,而該邏輯合約不知道該變數,則該邏輯合約可能會在同一插槽中儲存一些其他資料,從而覆蓋代理的關鍵資訊。Zeppelin的三種模式提供了不同的方法來構建,以使合約可以透過代理進行升級。

使用繼承儲存實現可升級

繼承儲存[6]方式需要邏輯合約包含代理合約所需的儲存結構。代理和邏輯合約都繼承相同的儲存結構,以確保兩者都儲存必要的代理狀態變數。

對於這種方式,我們使用Registry合約來跟蹤邏輯合同的不同版本。為了升級到新的邏輯合同,開發者需要在註冊合約中將新升級的合約進行註冊,並要求代理升級到新合約。需要注意的是,擁有註冊合約並不會影響儲存機制。實際上,本文講述的這幾種儲存模式都可以實現該機制。

如何初始化

1.部署Registry合約2.部署初始版本目標合約(v1)。確保它繼承了可升級合約3.將初始版本的目標合約地址註冊到 Registry合約4.請求Registry合約,建立一個UpgradeabilityProxy例項5.請求UpgradeabilityProxy,升級到目標合約的初始版本

如何升級

1.部署從初始版本繼承的新版本合約(v2),並確保新版本合約保留代理的儲存結構和初始版本合約的儲存結構。2.將新版本的合約註冊到 Registry3.請求UpgradeabilityProxy,將目標合約升級為新版本。

重要要點

我們仍然可以透過UpgradeabilityProxy合約,來呼叫新版本目標合約引入的新方法或新變數。

使用永久儲存實現可升級

在Eternal Storage模式中[7],儲存結構是在單獨的合約中定義,代理合約和邏輯合約都繼承儲存合約。儲存合約包含邏輯合約所需的所有狀態變數,同時,代理合約也能夠識別這些狀態變數,因此代理合約在定義升級所需要的狀態變數時,不必擔心所定義的狀態變數會被覆蓋。請注意,邏輯合約的後續版本均不應定義任何其他狀態變數。邏輯合約的所有版本都必須始終使用最開始定義儲存結構。

Zeppelin在實現這種儲存代理模式時,引入了代理所有權的概念。只有代理所有者有權將新版本合約寫入代理合約中,或者將所有權進行移交。

如何初始化

1.部署EternalStorageProxy合約2.部署初始版本目標合約(v1)3.呼叫EternalStorageProxy合約,將初始版本的目標合約地址註冊到代理合約中4.如果您的邏輯合約依賴建構函式來設定一些初始狀態,則在註冊到代理合約之後必須重新初始化,這是因為代理的儲存不知道這些值。EternalStorageProxy具有upgradeToAndCall方法專門用於在代理合約中呼叫升級後目標合約,進行目標合約的初始引數的賦值。

如何升級

1.部署(v2)版本的目標合約,確保其擁有永久的儲存結構。2.呼叫EternalStorageProxy,將合約升級到新版本。

重要要點

新版本合約可以升級現有合約的方法或引入新的方法,但是不能引入新的狀態變數。

使用非結構化儲存實現可升級

非結構化儲存[8]模式類似繼承儲存模式,但並不需要目標合約繼承與升級相關的任何狀態變數。此模式使用代理合約中定義的非結構化儲存插槽來儲存升級所需的資料。

在代理合約中,我們定義了一個常量變數,在對它進行Hash時,應提供足夠隨機的儲存位置來儲存代理合約呼叫邏輯合約的地址。

bytes32 private constant implementationPosition = 
keccak256("org.zeppelinos.proxy.implementation");

由於常量[9]不會佔用儲存插槽,因此不必擔心implementationPosition被目標合約意外覆蓋。由於Solidity狀態變數儲存[10]的規定,目標合約中定義的其他內容使用此儲存插槽衝突的可能性極小。

透過這種模式,邏輯合約不需要知道代理合約的儲存結構,但是所有未來的邏輯合約都必須繼承其初始版本定義的儲存變數。就像在繼承儲存模式中一樣,將來升級的目標合約可以升級現有功能以及引入新功能和新儲存變數。

Zeppelin在實現這種儲存代理模式時,引入了代理所有權的概念。只有代理所有者有權將新版本合約寫入代理合約中,或者將所有權進行移交。

如何初始化

1.部署OwnedUpgradeabilityProxy合約2.部署初始版本(v1)的目標合約3.呼叫OwnedUpgradeabilityProxy合約將初始版本的目標合約註冊到代理合約中4.如果您的邏輯合約依賴於其建構函式來設定一些初始狀態,則在註冊到代理之後必須重做,因為代理的儲存不知道這些值。OwnedUpgradeabilityProxy提供upgradeToAndCall函式專門用於在代理合約中呼叫目標合約的函式,對引數進行初始化。

如何升級

1.部署(v2)版本的合約,確保它繼承了先前版本中使用的狀態變數。2.呼叫OwnedUpgradeabilityProxy,將目標合約升級到新版本。

重要要點

這種方式最實用,目標合約與代理合約耦合性最低。

關於合約升級

重要提示:如果您的邏輯合約依賴於其建構函式來設定一些初始狀態,則在註冊到代理合約後需要重新初始化該引數。例如,目標合約繼承Zeppelin的Ownable,還會繼承Ownable的建構函式,該建構函式設定了建立合約時所有者的地址。當您註冊到代理合約,從代理合約呼叫目標合約時,從代理合約的的角度來看所有者的地址並沒有初始化。

解決該問題的常見方式是,代理合約呼叫目標合約上的initialize方法。initialize方法實現了建構函式中的邏輯。除此之外,還需要一個標識,使得某些初始變數只能夠被賦值一次。

您的目標合約應如下所示:

[contract Token is Ownable {
   ...
   bool internal _initialized;

   function initialize(address owner) public {
      require(!_initialized);
      setOwner(owner);
      _initialized = true;
   }
   ...
}

根據不同的部署策略,可以使用專門的部署合約來部署所有合約,也可以將代理合約和目標合約分開部署,然後使用如下upgradeToAndCall方法所示將目標合約註冊到代理合約中 :

[const initializeData = encodeCall('initialize', ['address'], [tokenOwner]) await proxy.upgradeToAndCall(logicContract.address, initializeData, {
    from: proxyOwner
})

結論

代理模式的概念已經存在了一段時間,但由於其複雜性,擔心引入安全漏洞以及繞過區塊鏈不可篡改而引起爭論,尚未得到廣泛採用。過去的解決方案也相當僵化,使得目標合約可以修改和新增的內容受到嚴格限制。但是,從開發人員的角度來看,很顯然需要升級合約的功能。Zeppelin為他們探索的三種代理模式提供了程式碼和測試,以幫助開發人員設計在其專案中引入合約的可升級性。

儘管代理模式的概念並不是什麼新概念,但它的採用仍為時過早,令人興奮的是,看到這種正規化可以實現更高階的DApp架構。如果您使用代理模式構建了某些內容,請在Twitter上讓我[11]和Zeppelin[12]知道,然後加入Zeppelin slack channel[13]以在此處進行展示

進一步閱讀

Zeppelin團隊目前正在採用非結構化儲存方式,作為Zeppelin在EVM之上實現去中心化平臺和工具zeppelin_os的部分功能。非結構化儲存方式具有巨大的優勢,它透過引入一種新穎的方式來維護代理所需的儲存變數,而不用侵入目標合約。感興趣的讀者可以閱讀有關Zeppelin 在即將釋出的zeppelin_os Kernel發行版中使用非結構化儲存的[14]更多資訊。

Zeppelin還在Eternal Storage 技術部落格[15]釋出了詳細的介紹文章。

一年多以前,Aragon和Zeppelin 聯手在代理庫上寫了兩個部落格文章,這些文章可以在這裡[16]和這裡[17]找到。

Arachnid、Nick Johnson或go-ethereum的核心開發人員、ENS的首席開發人員在兩年多以前出版的核心文章[18]中發表了對Upgradeable&Dispatcher合約的看法。

如果您希望構建簡單的東西,並且不會在未來的合約有巨大的變化,則可以參考這個非常簡單的示例[19]。

Solidity文件總是很有幫助,建議您檢視Solidity的delegate call function[20]和assembly opcodes[21].

上述文章中的所有圖都是使用此 Figma file 上製作,您可以隨意複製到自己的圖中

2018 年 12 月更新:自從本文最初發表以來,我們一直在 ZeppelinOS 努力改進我們在庫中使用的代理模式。在此處閱讀最新資訊,並檢視ZeppelinOS 稽覈實現的那些模式。

免責聲明:

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

推荐阅读

;