OpenZeppelin 可昇級合約插件 6/9 代理合約可昇級模式

以太坊 ethereum andy 4年前 (2020-11-02) 1508次浏览 已收录 0个评论 扫描二维码
文章目录[隐藏]

OpenZeppelin 可昇級合約插件 6/9 代理合約可昇級模式

本文介紹的是 “非結構化存儲” 的代理模式,這個模式是 OpenZeppelin 可昇級合約的基本組成部份。
更深入的代理模式討論可以參考 https://blog.openzeppelin.com/proxy-patterns/

為什麼要昇級合約?

從設計上來說,智能合約是不可改變的。另一方面,軟體的品質就取決於昇級和修補源碼,以產生更迭的版本。就算是基於區塊鏈的軟體,從技術上的不可變性得到的明顯的好處(意指不可改變性得到用戶的信任),但是對於錯誤修復和潛在的產品改進來說,仍需要一定程度的可變性。OpenZeppelin 可昇級合約解決了這個明顯的矛盾,透過簡單使用的,健壯的,可選性的昇級機制,使得合約可以被治理、控制。無論是多簽名錢包,簡單的地址,或是複雜的 DAO (Decentralized Authentication Organization, 去中心的權威機構)

使用代理模式來達成昇級的目地

基本的想法,是透過使用合約代理,來達成昇級的目的。第一個合約是一個簡單的包裝合約,或是稱為代理合約,跟用戶直接交互並且負責將交易轉發到第二個合約以及從第二個合約轉出。第二合約包括業務邏輯。要了解的關鍵概念是可以在代理合約或訪問點沒有被改變的情況下替換邏輯合約。
在不能更改合約代碼的意義上來說,這兩個合約代碼仍然是不變的,但是邏輯合約可以簡單的被另一個合約替換。透過指向不同的邏輯合約(功能實作合約),來達成軟體的可昇級。

OpenZeppelin 可昇級合約插件 6/9 代理合約可昇級模式

代理轉發

代理合約最需要解決的直接問題,就是不需要對整個邏輯合約的接口進行一對一的映射,而公開整個邏輯合約接口。如果需要一對一映射,將會非常難以維護,容易出錯,使得接口無法昇級。
因此,一個動態的轉發機制是必要的。下面的代碼展示了這種機制的基礎

assembly { 
    let ptr := mload(0x40) 
    // (1) copy incoming call data 
    calldatacopy(ptr, 0, calldatasize) 

    // (2) forward call to logic contract 
    let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0) 
    let size := returndatasize 

    // (3) retrieve return data 
    returndatacopy(ptr, 0, size) 
    
    // (4) forward return data back to caller 
    switch result 
    case 0 { revert(ptr, size) } 
    default { return(ptr, size) } 
}

這個代碼可以放在代理合約的 fallback 函數裡面,然後會轉發所有的調用函數跟參數給邏輯合約,不需要知道邏輯合約的任何特定的接口。
第一步, calldatacopy 將所有資料複製到記憶體裡面,第二步轉發調用到邏輯合約,第三步,接收調用邏輯合約的返回值,第四步返回的資料轉發回調用者。這個技術需要使用 Yul 來實作,因為 Solidity 的 delegatecall 返回布林值而不是被調用者返回的資料。
一件很重要的事情需要注意,就是以上代碼,是使用 EVM 的 delegatecall opcode , 這會讓被調用者的代碼執行在被調用者的環境裡面。所以在邏輯合約裡面來控制代理合約的狀態,而且邏輯合約自己的狀態是無用的。因此代理合約並不是只把交易轉送到邏輯合約裡面,也存放了這兩個合約有效的狀態。狀態在代理合約中,而真實邏輯在代理合約指向的邏輯合約實現裡。

非結構化存儲的代理合約

使用代理合約的時候,很快就會出現一個問題,就是哪一個變數是存在於代理合約之中。假設代理合約把邏輯合約的地址存放在 address public _implementation 然後,再假設邏輯合約是一個普通的代幣合約,第一個變數是 address public _owner. 這兩個變數都是 32byte ,按照 EVM 的執行順序來看,都佔用了第一個位置。當邏輯合約寫入 _owner 時,因為邏輯合約在代理合約的環境之中(使用 delegatecall 的 opcode 操作),實際上寫入了 _implementation 。這個問題可以稱為”存儲衝突”(storage collision)

OpenZeppelin 可昇級合約插件 6/9 代理合約可昇級模式
有很多方式可以克服這個問題,以下 OpenZeppelin 可昇級合約採用的方式稱為”非結構化存儲”(unstructure storage)。存儲代理合約中第一個變數位置 _implementation 地址,改成儲存在一個假的隨機位置。這個足夠隨機的位置讓邏輯合約宣告變數時不太可能用到這個位置。代理合約中的其他變數,也使用了同樣的隨機位置原理,像是 管理員 地址 也是如此,管理員地址用來更新 _implementation 的值。

OpenZeppelin 可昇級合約插件 6/9 代理合約可昇級模式
以下是一個實現隨機化存儲的範例,遵循 EIP-1967

bytes32 private constant implementationPosition = bytes32(uint256(              
    keccak256('eip1967.proxy.implementation')) - 1 
));

這樣一來,邏輯合約就不用擔心會覆蓋代理合約的變數。其他代理合約實作在面對這個問題時,通常是讓代理合約知道邏輯合約的存儲結構然後適應邏輯合約,或是讓邏輯合約知道代理合約的存儲結構然後適應代理合約。這就是為什麼這個方式稱為”非結構化存儲”,因為合約之間不用知道其他合約的存儲結構。

功能實作合約之間的存儲衝突

像之前說的,非結構化的方式可以避開邏輯合約和代理合約之間的存儲衝突,但是,在不同版本的邏輯合約之間存儲衝突還是會發生。舉個例子,想像一下邏輯合約第一個存儲實作是 address public _owner 放在第一個位置,而另一個昇級版本的的邏輯合約第一個位置則是 address public _lastContributor 。當昇級的邏輯合約嘗試寫入 _lastContributor 變數,會使用上一個合約 _owner 的相同存儲位置,然後覆蓋原來的值。
錯誤的存儲如下所示

OpenZeppelin 可昇級合約插件 6/9 代理合約可昇級模式
正確的存儲如下所示

OpenZeppelin 可昇級合約插件 6/9 代理合約可昇級模式
非結構化存儲的機制,並不能防範這個狀況。所以由用戶來決定是原來合約邏輯的新版本,還是保證存儲的結構是擴展而不是修改。OpenZeppelin 可昇級合約插件會檢測這個存儲衝突的情況,然後發出警告給開發者。

建構函數警告

在 solidity 中,在建構函數中的代碼或全局變數,不是執行運行時的 bytcode 。代碼只會在部署的時候執行一次。這個情況的結果就是邏輯合約建構函數的代碼,不會在代理合約的環境中被執行。換句話說,代理合約完全不考慮的建構函數,就像不存在。這個問題很容易解決。邏輯合約建構函數內的代碼應該移到一般合約 initializer 函數中,而且代理合約調用邏輯合約時也被調用。需要特別注意的只有, initializer 函數應該只能被執行一次,這是一般編程中構造函數的屬性之一。
以下是我們建立 OpenZeppelin 可昇級合約的方法,可以寫 initializer 函數,以及傳入參數。
使用修飾器來確保 initialze 函數只能被執行一次。OpenZeppelin 可昇級合約透過提供 Initializable 合約來提供這個功能。

// contracts/MyContract.sol 
// SPDX-License-Identifier: MIT 
pragma solidity ^0.6.0; 

import "@openzeppelin/upgrades/contracts/Initializable.sol"; 


contract MyContract is Initializable { 
    function initialize( 
        address arg1, 
        uint256 arg2, 
        bytes memory arg3 
    ) public payable initializer { 
        // "constructor" code... 
    }
}

注意到如何擴展 Initializable 合約,以及實作 initializer 函數。

透明代理 和 函數衝突

如同前面的章節所說,可昇級的合約實體(或是稱為代理合約)是採用委託所有調用的方式,把所有調用委派給邏輯合約。但是代理合約本身也需要自己的函數,例如 upgradeTo(address) 用來昇級新的邏輯合約。這引出一個問題,就是邏輯合約也有一個相同名稱的函數 upgradeTo(address) , 有一個這個函數的調用,是調用代理合約上的還是調用邏輯合約上的?
注意:函數衝突也可能發生在不同名的函數上。每一個函數的對外公開 ABI 都是確定的,在 bytecode 層級是 4byte 的定義。這個定義取決於函數名稱和功能。但由於只有 4 byte ,所以仍然有可能發生兩個不同的函數最後結果有相同的 byteocde 定義名稱。Solidity 的編譯器會追縱同一個合約中是否發生這個情況,但是不會追蹤跟另一個合約內是否衝突,例如,不會追蹤代理合約跟邏輯合約的衝突。這個情況可以參考 https://medium.com/nomic-labs-blog/malicious-backdoors-in-ethereum-proxies-62629adf3357
OpenZeppelin 可昇級合約是透過”透明代理模式”( transparent proxy pattern) 來處理這個問題。透明代理合約是由調用的地址,例如 msg.sender ,來決定哪個調用需要委派給邏輯合約。
如果調用者是 proxy 的管理員(該地址有昇級代理合約的權限), 代理合約就不委派給邏輯合約,並且回應任何代理合約能回應的消息。
如果調用者是其他地址,代理合約就會將調用委派給邏輯合約,不論函數名稱是否相同。
假設代理合約有 owner() 及 upgradeTo() 兩個函數,委派調用的ERC20邏輯合約有 owner() 和 transfer() 函數,下面的對照表顯示了委派結果

OpenZeppelin 可昇級合約插件 6/9 代理合約可昇級模式
OpenZeppelin 可昇級合約,對於這個情況採用了一個中介合約(intermediary) 叫做 代理管理合約 ProxyAdmin Contract ,這個合約用來管理所有建立的可昇級合約。就算從節點的預設帳戶部署合約,代理管理合約還是實際會管理所有合約。這表示可以用節點的帳戶來跟所有的代理合約交互。只有那些需要從 Solidity 手動建立代理合約的進階用需要特別注意到透明代理模式的規則。

總結

任何使用可昇級合約的開發者,都應該熟悉本篇所描述的代理合約模式。最後,概念很簡單,OpenZeppline 的可昇級合約是設計來包住代理合約的機制,包括所有需要注意的事情,使得開發時專案可以最小化。

  • 對什麼是代理合約有基本了解
  • 用擴充存儲來取代修改存儲
  • 確定合約使用 initialize 函數來取代建構函數

此外,OpenZeppelin 可昇級合約插件在以上問題發生時會提示發生了什麼問題。

參考

https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies
https://eips.ethereum.org/EIPS/eip-1967


神隊友學長Andy , 版权所有丨如未注明 , 均为原创丨本网站采用BY-NC-SA协议进行授权
转载请注明原文链接:OpenZeppelin 可昇級合約插件 6/9 代理合約可昇級模式
喜欢 (0)
[[email protected]]
分享 (0)
andy
关于作者:
中年大叔,打拼 like young students.
发表我的评论
取消评论
表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址