本文翻譯自Zeppelin于2018年發表的關于《代理模式》文章。所有權歸原文作者所有。
原文鏈接:https://blog.openzeppelin.com/proxy-patterns/
以太坊的最大優勢之一是其公共賬本內交易記錄的不可篡改性,這些交易包括Token的轉移,合約的部署以及合約交易。以太坊網絡上的任何節點都可以驗證每筆交易的有效性和狀態,從而使以太坊成為一個非常強大的去中心化系統。
但最大的缺點是,智能合約一旦部署后,則無法更改合約源碼。中心化應用程序(例如Facebook或Airbnb)的開發人員會經常對程序進行更新,修復bug或引入新功能。而這種方式在以太坊上是不可能做到的。
還記得著名的Parity Wallet 事件,黑客盜取了150000個ETH,在這次的攻擊中,Parity multisig錢包中一個合約的漏洞被黑客利用,盜取了錢包中的資金。在黑客攻擊過程中,我們唯一能做的就是利用相同的漏洞,比黑客更快速的將錢包中的資金進行轉移,并在事后歸還給所有者。
如果有一種方法可以在智能合約部署后,更新源代碼……
引入代理模式
雖然無法更新已部署的智能合約代碼,但是可以通過設置一個代理合約架構,進而部署新的合約,以實現合約升級的目的。
代理模式使得所有消息調用都通過代理合約,代理合約會將調用請求重定向到最新部署的合約中。如要升級時,將升級后新合約地址更新到代理合約中即可。
Zeppelin在實現zeppelin_os的過程中一直在研究幾種代理模式。探索了三種代理模式:
- 繼承存儲
- 永久存儲
- 非結構化存儲
這三種模式底層都依賴delegatecalls
來實現。雖然Solidity提供了delegatecall
方法,但它僅在調用成功后返回true / false,無法管理返回的數據。
在深入研究之前,需要先理解兩個重要的概念:
- 當調用的方法在合約中不存在時,合約會調用
fallback
函數。可以編寫fallback
函數的邏輯處理這種情況。代理合約使用自定義的fallback
函數將調用請求重定向到其他合同中。 - 每當合約A將調用代理到另一個合同B時,它都會在合約A的上下文中執行合約B的代碼。這意味著將保留msg.value和msg.sender值,并且每次存儲修改都會影響合約A。
所有代理模式都繼承了Zeppelin’s Proxy contract,該合約實現了自己的代理調用函數,該函數返回調用邏輯合約的值。如果您打算使用Zeppelin的代理合約代碼,需要詳細了解代理合約代碼。讓我們探索它是如何工作的,并了解它用于實現代理的匯編操作碼。(參考Solidity的Assembly文檔以獲取更多信息)
[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,大小是不固定的,數據大小存儲在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的三種模式提供了不同的方法來構建,以使合約可以通過代理進行升級。
使用繼承存儲實現可升級
繼承存儲方式需要邏輯合約包含代理合約所需的存儲結構。代理和邏輯合約都繼承相同的存儲結構,以確保兩者都存儲必要的代理狀態變量。
對于這種方式,我們使用Registry
合約來跟蹤邏輯合同的不同版本。為了升級到新的邏輯合同,開發者需要在注冊合約中將新升級的合約進行注冊,并要求代理升級到新合約。需要注意的是,擁有注冊合約并不會影響存儲機制。實際上,本文講述的這幾種存儲模式都可以實現該機制。
如何初始化
- 部署
Registry
合約 - 部署初始版本目標合約(v1)。確保它繼承了可升級合約
- 將初始版本的目標合約地址注冊到
Registry
合約 - 請求
Registry
合約,創建一個UpgradeabilityProxy
實例 - 請求
UpgradeabilityProxy
,升級到目標合約的初始版本
如何升級
- 部署從初始版本繼承的新版本合約(v2),并確保新版本合約保留代理的存儲結構和初始版本合約的存儲結構。
- 將新版本的合約注冊到
Registry
- 請求
UpgradeabilityProxy
,將目標合約升級為新版本。
重要要點
我們仍然可以通過UpgradeabilityProxy
合約,來調用新版本目標合約引入的新方法或新變量。
使用永久存儲實現可升級
在Eternal Storage模式中,存儲結構是在單獨的合約中定義,代理合約和邏輯合約都繼承存儲合約。存儲合約包含邏輯合約所需的所有狀態變量,同時,代理合約也能夠識別這些狀態變量,因此代理合約在定義升級所需要的狀態變量時,不必擔心所定義的狀態變量會被覆蓋。請注意,邏輯合約的后續版本均不應定義任何其他狀態變量。邏輯合約的所有版本都必須始終使用最開始定義存儲結構。
Zeppelin在實現這種存儲代理模式時,引入了代理所有權的概念。只有代理所有者有權將新版本合約寫入代理合約中,或者將所有權進行移交。
如何初始化
- 部署
EternalStorageProxy
合約 - 部署初始版本目標合約(v1)
- 調用
EternalStorageProxy
合約,將初始版本的目標合約地址注冊到代理合約中 - 如果您的邏輯合約依賴構造函數來設置一些初始狀態,則在注冊到代理合約之后必須重新初始化,這是因為代理的存儲不知道這些值。
EternalStorageProxy
具有upgradeToAndCall
方法專門用于在代理合約中調用升級后目標合約,進行目標合約的初始參數的賦值。
如何升級
- 部署(v2)版本的目標合約,確保其擁有永久的存儲結構。
- 調用
EternalStorageProxy
,將合約升級到新版本。
重要要點
新版本合約可以升級現有合約的方法或引入新的方法,但是不能引入新的狀態變量。
使用非結構化存儲實現可升級
非結構化存儲模式類似繼承存儲模式,但并不需要目標合約繼承與升級相關的任何狀態變量。此模式使用代理合約中定義的非結構化存儲插槽來保存升級所需的數據。
在代理合約中,我們定義了一個常量變量,在對它進行Hash時,應提供足夠隨機的存儲位置來存儲代理合約調用邏輯合約的地址。
bytes32 private constant implementationPosition =
keccak256("org.zeppelinos.proxy.implementation");
由于常量不會占用存儲插槽,因此不必擔心implementationPosition
被目標合約意外覆蓋。由于Solidity狀態變量存儲的規定,目標合約中定義的其他內容使用此存儲插槽沖突的可能性極小。
通過這種模式,邏輯合約不需要知道代理合約的存儲結構,但是所有未來的邏輯合約都必須繼承其初始版本定義的存儲變量。就像在繼承存儲模式中一樣,將來升級的目標合約可以升級現有功能以及引入新功能和新存儲變量。
Zeppelin在實現這種存儲代理模式時,引入了代理所有權的概念。只有代理所有者有權將新版本合約寫入代理合約中,或者將所有權進行移交。
如何初始化
- 部署
OwnedUpgradeabilityProxy
合約 - 部署初始版本(v1)的目標合約
- 調用
OwnedUpgradeabilityProxy
合約將初始版本的目標合約注冊到代理合約中 - 如果您的邏輯合約依賴于其構造函數來設置一些初始狀態,則在注冊到代理之后必須重做,因為代理的存儲不知道這些值。
OwnedUpgradeabilityProxy
提供upgradeToAndCall
函數專門用于在代理合約中調用目標合約的函數,對參數進行初始化。
如何升級
- 部署(v2)版本的合約,確保它繼承了先前版本中使用的狀態變量。
- 調用
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上讓我和Zeppelin知道,然后加入Zeppelin slack channel以在此處進行展示??
進一步閱讀
Zeppelin團隊目前正在采用非結構化存儲方式,作為Zeppelin在EVM之上實現去中心化平臺和工具zeppelin_os的部分功能。非結構化存儲方式具有巨大的優勢,它通過引入一種新穎的方式來維護代理所需的存儲變量,而不用侵入目標合約。感興趣的讀者可以閱讀有關Zeppelin 在即將發布的zeppelin_os Kernel發行版中使用非結構化存儲的更多信息。
Zeppelin還在Eternal Storage 技術博客發布了詳細的介紹文章。
一年多以前,Aragon和Zeppelin 聯手在代理庫上寫了兩個博客文章,這些文章可以在這里和這里找到。
Arachnid、Nick Johnson或go-ethereum的核心開發人員、ENS的首席開發人員在兩年多以前出版的核心文章中發表了對Upgradeable&Dispatcher合約的看法。
如果您希望構建簡單的東西,并且不會在未來的合約有巨大的變化,則可以參考這個非常簡單的示例。
Solidity文檔總是很有幫助,建議您查看Solidity的delegate call function和assembly opcodes.
上述文章中的所有圖都是使用此Figma file上制作,您可以隨意復制到自己的圖中??
2018年12月更新:自從本文最初發表以來,我們一直在ZeppelinOS努力改進我們在庫中使用的代理模式。在此處閱讀最新信息,并查看ZeppelinOS審核實現的那些模式。