智能合約升級模式介紹 — 入門篇

以太坊最大的優點就是,每一筆用來轉帳、部署合約或者和合約交互的交易(事務)都被存在一個叫作區塊鏈的公共帳本上。一旦交易發生,就再也沒法隱藏或者改變。這帶來一個巨大的好處,就是在以太坊中的每個節點均可以去驗證任意一筆交易的合法性和當前狀態。這使得以太坊成爲一個很是健壯的去中心化系統。安全

可是隨之而來的是,它還有一個最大的缺點,就是智能合約一旦部署以後,就再也沒法改變源碼。開發中心化應用(好比facebook或者Airbnb)的開發者,都已經習慣了,爲了修復bug或者引入新的特性而頻繁更新產品。但這種方式卻不適用以太坊。數據結構

還記得當面Parity多簽名錢包被黑致使150000以太幣被偷的惡劣事件嗎?在整個攻擊中,就由於錢包中的一個bug致使不少鉅額錢包的資金被清空。而惟一的解決方案就是嘗試以比黑客更快的速度,利用相同的漏洞攻擊剩餘的錢包,來把以太幣從新分配給它們合法的全部者。架構

要是有一種方法能夠在智能合約部署以後,還能對它們進行升級,那該多好...函數

引入代理模式

儘管想升級已經部署的智能合約中的代碼是不可能的,可是能夠經過設計一個代理合約結構,這個結構可讓你能夠經過新部署一個合約的方式,來實現升級主要的處理邏輯的目的。學習

代理結構模式就像下面這張圖同樣:全部消息經過一個代理合約來間接調用最新部署的邏輯合約。若是想要升級的話,只須要部署一個新的合約,而後在代理合約中更新引用新的合約地址就能夠了。區塊鏈

1Proxy0

做爲實現zeppelin_os的一部分,zeppelin正致力於實現集中代理模式。目前已經探索出來的有下面三個:測試

  1. 繼承存儲模式 Inherited Storage
  2. 永久存儲模式 Eternal Storage
  3. 非結構化存儲模式 Unstructured Storage

全部三種模式都依賴低階的delegatecall。儘管solidity提供了一個delegatecall方法,但它只能返回true或者false來顯示調用是否成功,而不是容許你操做返回的數據。ui

在咱們深刻了解以前,理解兩個關鍵的概念很重要:spa

  • 當調用一個合約中並不支持的的方法時,就會調用合約中的fallback方法。你能夠本身寫一個fallback函數來處理這種場景。代理合約就是用自定義的fallback方法將調用重定向到其餘合約實現。
  • 每當合約A受權對另外一個合約B的調用時,它就會在合約A的上下文中執行合約B的代碼。這就意味着msg.valuemsg.sender的值會被保留。而且對存儲的修改將會做用在合約A的存儲上。

zeppelin的代理合約,爲了能夠返回調用邏輯合約後的結果,實現了本身的delegatecall方法,全部模式都是這樣。若是你想要使用zeppelin的代理合約代碼,你就要理解代碼的每個細節。讓咱們先來看看它是如何發揮做用的,以及理解爲了達到目的它所使用的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) }
 }

爲了受權對另外一個合約中方法的調用,咱們須要把它賦值給proxy合約接收的msg.data。由於msg.databytes類型的,是一個動態的數據結構,因此它在msg.data的第一個字(word,也就是32個字節)中的存儲長度會不同。若是咱們想要只取出真正的數據,咱們須要跳過第一個字(word),從msg.data0x20(32個字節)開始。然而,咱們會用到兩個操做碼來實現此目的。咱們會使用calldatasize來獲取msg.data的大小,以及calldatacopy來把它複製到ptr所指向的位置。

注意到咱們是如何初始化ptr變量的。在solidity中,內存槽中的0x40位置是和特殊的,由於它存儲了指向下一個可用自由內存的指針。每次當你想往內存裏存儲一個變量時,你都要檢查存儲在0x40的值。這就是你變量即將存放的位置。如今咱們知道了咱們要在哪兒存變量,咱們就可使用calldatacopy,把大小爲calldatasize的calldata從0開始啊複製到ptr指向的那個位置了。

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

咱們再看看下面的assembly代碼(使用delegatecall操做碼)

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

解釋一下上面的參數:

  • gas:函數執行所需的gas
  • _impl:咱們調用的邏輯合約的地址
  • ptr:內存指針(指向數據開始存儲的地方)
  • calldatasize:傳入的數據大小
  • 0:調用邏輯合約後的返回值。咱們沒有使用這個參數由於咱們還不知道返回值的大小,因此不能把它賦值給一個變量。咱們能夠後面能夠進一步使用returndata操做碼來獲取這些信息。
  • 0:返回值的大小。這個參數也沒有被使用由於咱們沒有機會創造一個臨時變量用來存儲返回值。鑑於咱們在調用其餘合約以前沒法知道它的大小(因此就沒法創造臨時變量呀)。咱們稍後能夠用returndatasize操做碼來獲得這個值。

下面一行代碼就是用returndatasize操做碼獲得了返回數據的大小:

let size := returndatasize

咱們使用這個返回值的大小,來把返回值複製到ptr指向的內存,使用returndatacopy來達到這個目的:

returndatacopy(ptr, 0, size)

最後,switch語句要麼返回 【返回值】,要麼拋出錯誤,若是發生錯誤的話。

storage_proxy

很好,咱們如今有了一個從邏輯合約中獲取正確結果的方法。

如今,咱們理解了代理合約是如何工做的。下面就讓咱們正式學習三種模式:繼承存儲模式、永久存儲模式和非結構化存儲模式。

這三種方法用不一樣的方法來解決同一個難點:怎樣確保邏輯合約不會重寫/覆蓋代理中的狀態變量。

任何代理結構模式的主要問題就是如何分配存儲。記住,既然咱們使用一個合約來存儲,另外一個合約來實現邏輯,它們中的任何一個都有可能重寫一個已經使用的存儲槽。這意味着若是代理合約有一個狀態變量在某個存儲槽中存儲着最新的邏輯合約地址,可是邏輯合約殊不知道的話,那麼邏輯合約可能就會在那個槽中存一些其餘數據,這樣就把代理合約中的重要信息覆蓋了。zeppelin的這三種方法表明了架構合約系統的三種途徑,實現經過代理模式升級合約的目的。

使用繼承存儲模式升級

繼承存儲方法要求邏輯合約內部也實現代理合約內的存儲結構。代理合約和邏輯合約都要繼承徹底同樣的存儲結構,來確保兩者都支持存儲必要的代理合約的狀態變量。

當探索這種模式時,咱們有這樣一個想法,咱們想要有一個Registry合約來追蹤不一樣版本的邏輯合約。 爲了升級成新的邏輯合約,你須要爲它在Registry裏註冊一個新的版本,而且要求代理合約中也升級成這個最新版本的邏輯合約。注意到有一個Registry合約並不影響存儲機制,實際上,它能夠應用到這篇文章中提到的任意一種存儲模式中。

inherit_storage

如何初始化

  1. 部署Registry合約
  2. 部署一個邏輯合約的最第一版本(V1),確保它繼承了Upgradeable合約
  3. Registry合約中註冊這個最第一版本(V1)的地址
  4. 要求Registry合約建立一個UpgradeabilityProxy實例
  5. 調用你的UpgrageabilityProxy實例來升級到你最第一版本(V1)

如何升級

  1. 部署一個繼承了你最第一版本合約的新版本(V2),==確保它保留了代理合約和最第一版本邏輯合約中的存儲結構==
  2. Registry中註冊合約的新版本
  3. 調用你的UpgradeabilityProxy實例來升級到最新註冊的版本

tips

咱們能夠在將來部署的邏輯合約中升級現有方法、創造新的方法以及新的狀態變量,但仍然調用同一個UpgradeabilityProxy合約。

使用永久存儲模式升級

在永久存儲模式中,存儲模式用一個獨立的合約(代理和邏輯合約都要繼承這個合約)來定義。這個存儲合約保留了全部邏輯合約須要的狀態變量,由於代理合約也會知道這些變量的存在(由於繼承),它就能夠爲升級定義本身的狀態變量,不用考慮覆蓋變量這些問題。注意到全部的版本的邏輯合約都不能夠再定義任何額外的狀態變量。全部版本的邏輯合約都必須一直使用一開始就定義好的永久存儲架構。

這種應用在zeppelin labs項目中提供了實現,而且同時引入了代理全部權的概念。一個代理的全部者是惟一一個能夠升級代理並指定一個新的邏輯合約的地址,也是惟一一個能夠轉移全部權的地址。

eternal_storage

如何初始化

  1. 部署一個EternalStorageProxy實例
  2. 部署一個邏輯合約的最第一版本(V1)
  3. 調用EternalStorageProxy實例來升級到這個最第一版本合約的地址
  4. 若是你的邏輯合約依賴本身的構造函數(constructor)來設置某個初始狀態,那麼在它和代理合約產生聯繫以後,以前的這些狀態就要從新修改,由於代理合約的存儲並不知道(邏輯合約裏的)這些值。EternalStorageProxy有一個叫upgradeToAndCall的函數專門來調用一些邏輯合約中的方法,一旦代理合約升級到最新版本時,就把鏈接到的那個邏輯合約裏的初始設置從新設置一遍。

如何升級

  1. 部署一個邏輯合約的最新版本(v2),確保它也包含永久存儲結構。
  2. 調用EternalStorageProxy實例來升級到最新版本。

tips

這是沒有增長太多開銷同時很直觀的邏輯合約。 之後的邏輯合約能夠升級現有的方法或者創造新的方法,可是不能引入新的狀態變量。

使用非結構化存儲升級

非結構化存儲模式和繼承存儲相似,可是不要求邏輯合約繼承任何和升級相關的狀態變量。這個模式使用代理合約中定義的非結構化的存儲槽來保存升級所需的數據。

在代理合約中,咱們定義了一個常量,每當哈希的時候,就給出一個足夠隨機的存儲位置來存儲代理合約須要調用的邏輯合約的地址。

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

由於常量(恆定)狀態變量並不佔用存儲槽,因此並不用擔憂implementationPosition會不當心被邏輯合約佔用。鑑於solidity在存儲中放置狀態變量的方法,依然有很是很是很是小的機率可能發生要存儲新變量的存儲槽已經被佔用了。

經過使用這種模式,任何版本的邏輯合約都不須要知道代理合約的存儲結構,可是全部後一個版本的邏輯合約都必須繼承上一個版本的存儲變量。就像在繼承存儲模式中同樣,將來的邏輯合約能夠更新現有的方法,也能夠建立新的方法和新的狀態變量。

這個模式也使用了代理合約全部權的概念。只有代理合約的全部者能夠更新邏輯合約的地址,也是惟一能夠轉移全部權的地址。

unstructured_storage

如何初始化

  1. 部署OwnedUpgradeabilityProxy實例
  2. 部署邏輯合約的初始版本(V1)
  3. 調用OwnedUpgradeabilityProxy實例來更新到初始版本的邏輯合約
  4. 若是你的邏輯合約依賴本身的構造函數(constructor)來設置某個初始狀態,那麼在它和代理合約產生聯繫以後,以前的這些狀態就要從新修改,由於代理合約的存儲並不知道(邏輯合約裏的)這些值。OwnedUpgradeabilityProxy有一個upgradeToAndCall方法專門來調用一些邏輯合約中的方法,一旦代理合約升級到最新版本時,就把鏈接到的那個邏輯合約裏的初始設置從新設置一遍。

如何升級

  1. 部署一個新版本的邏輯合約(V2),確保它繼承了上一個版本里的狀態變量結構。
  2. 調用ownedUpgradeabilityProxy實例來升級到新版本合約的地址。

tips

這個方法很棒,由於它不須要邏輯合約知道它是整個代理系統的一部分。

關於升級

重要:若是你的邏輯合約依賴本身的構造器來設置一些初始狀態的話,這個過程在新版本的邏輯合約註冊到代理中時須要從新作一遍。舉個例子,邏輯合約繼承Zeppelin中的Ownable合約,這很常見。當你的邏輯合約繼承Ownable,它也就繼承了Ownable的構造器,構造器會在合約建立的時候就設置合約的全部者是誰。當你讓代理合約來使用你的邏輯合約的時候,代理合約是不知道邏輯合約的全部者是誰的。

升級代理合約的一種常見的模式就代理當即對邏輯合約調用一個初始化方法。這個初始化方法應該去模仿在構造器中作的一些事情。同時你也想要一個標識,用來確保你不能夠再次對同一個邏輯合約調用初始化方法。(只能調用一次)

你的邏輯合約看上去可能像下面這樣:

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架構經過這種方式得以實現。

相關文章
相關標籤/搜索