[Solidity Note] - 合約有漏洞?要升級?要修改?怎麼辦?學學部署可升級合約吧!

Introduction & 前言

Proxy

如何部署一個可升級合約?如果我們發布了一版合約,突然想到有些漏洞或者需要進行修改,該如何不影響已經使用的 Dapp 重新部署呢?

這邊筆記記錄如何部署一個可升級合約,當中也記錄一些筆者碰到的問題,希望能跟各位一起成長,如果內容有誤請盡情指出,謝謝。


Summary & 摘要

最基本的 Proxy 邏輯,可參考 Proxy Patterns

Proxy 過程

  1. 資料存在 Proxy Contract(代理合約) 內 - Eternal Storage(資料永存)
  2. 邏輯存在 Logic Contract(邏輯合約)

事前準備

要部署可升級合約需要準備三樣合約:

  1. Implementation Contract(實例合約) - 又稱邏輯合約,放邏輯程式碼的地方,之後可能會有 v2 v3…等等,需注意的是新版本的變數不能更改,否則會造成合約崩潰。
  2. Proxy Admin(管理合約) - 存放代理合約的擁有者,只有 Owner 才可以進行合約升級,其實也是透過 upgrade() 去呼叫代理合約的 upgradeTo(),其中有 onlyOwner 的檢查。也提供了其他各種管理功能,相關可參考 Openzeppelin Contract Doc - ProxyAdmin更新的邏輯
  3. Upgradeability Proxy(可升級代理合約) - 用來指向最新的 Implementation Contract 地址,代理合約的地址永遠不變,所以可以達到升級合約的效果。

需注意邏輯合約內引入的其他合約必須是可升級合約,整份合約才可以變成可升級合約,如果是使用 HardhatTruffle 可以使用套件 OpenZeppelin/openzeppelin-upgrades 檢查可升級性。


如何達到資料永久化?

變數的數值存在代理合約中,當中因為 Solidityconstructor 不是 runtime bytecode 的一部分,只會在部署的時候跑過一次,所以代理合約無法使用邏輯合約的 constructor,這邊需要使用 Initializable.solinitialize(),這樣就可以在部署的時候使用,詳細可參考 Writing Upgradeable Contracts - Initializers

1
2
3
4
5
6
7
8
9
10
11
12
13
// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract MyContract is Initializable {
uint256 public x;

function initialize(uint256 _x) public initializer {
x = _x;
}
}

整體概念圖

筆者在整理的時候整理了一下流程圖,如果內容有誤還請手下留情指出錯誤。

整體概念圖


Remix IDE 部署步驟

  1. 部署邏輯合約

透過 Remix 可以實作一個小型的可升級合約,首先到 Remix 並且串連本地,相關串聯方法可參考 [Solidity Note] - 透過工程師的方式發布 基於 ERC721 的 NFT使用 Remix IDE

串連本地後新增一個新合約 UpgradeContract.sol,內容打上下面內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";

contract UpgradeableWhiteListContract is Initializable, OwnableUpgradeable {

mapping(address => bool) private whiteList;
mapping(address => uint256) private mintLimit;

function initialize() public initializer {}

function SetWhiteList(address _address, uint256 _mintLimit) external {
whiteList[_address] = true;
mintLimit[_address] = _mintLimit;
}

function GetWhiteList(address _address) public view returns (bool, uint256){
return (whiteList[_address], mintLimit[_address]);
}

// 自毀合約 - 使用ownerRestricted修饰符来限定只有合约的所有者才能调用该函数
function destructContract(address payable target) public payable {
selfdestruct(target);
}
}

其中這份邏輯包含了 **ProxyAdmin(管理合約)TransparentUpgradeableProxy(代理合約)**,之後選取邏輯合約後直接點擊部署,請部署在測試鏈上,不知道怎麼拿測試幣跟部署到測試鏈上一樣可參考 [Solidity Note] - 透過工程師的方式發布 基於 ERC721 的 NFT領取測試幣

發佈合約

部署結束後請把地址存下來,這邊筆者測試地址為在 Rinkeby 測試網 的 0x991c4cfc4bcc06b40a94246cffa958239fc6ce1bEtherscan 傳送門

  1. 部署管理合約

接下來必須把管理合約部署上去,一樣直接選取左邊的 CONTRACT 後,選擇 ProxyAdmin.sol 然後發布到測試鏈上。

發布管理合約

部署結束後一樣把地址記錄下來,這邊筆者測試地址為在 Rinkeby 測試網 的 0x8d463795c20622ec2a4c53d3a2f9d5834c856eafEtherscan 傳送門

請務必記得這個管理合約的地址,之後升級合約需要靠這個合約來升級,如果要轉移合約也必須靠著個合約。

  1. 部署代理合約

最重要的合約就是我們的代理合約了,這邊代理合約只會在最初部署的時候使用一次,之後我們一律使用代理合約的地址就可以訪問到最新的邏輯合約。

一樣直接選取左邊的 CONTRACT 後,選擇 TransparentUpgradeableProxy.sol 然後填寫該填寫的 DEPLOY 參數,最後發布到測試鏈上。

  • _LOGIC 請填入邏輯合約地址 0x991c4cfc4bcc06b40a94246cffa958239fc6ce1b
  • ADMIN_ 請填入管理合約地址 0x8d463795c20622ec2a4c53d3a2f9d5834c856eaf
  • _DATA 為可帶入初始化的資料,這邊筆者不填寫都會出錯,故填上網路上大部分教學的 0x8129fc1c 二進制碼即可成功,待解。

部署代理合約

部署結束後一樣把地址記錄下來,這邊筆者測試地址為在 Rinkeby 測試網 的 0x80d83e2b9bf263279319d2c90f17c8b86ce0c774Etherscan 傳送門

  1. 使用邏輯合約

這邊開始要升級合約,升級前後我們必須知道差異,所以我們先把邏輯合約引入近來使用,請記得這邊是使用剛剛部署代理合約的地址 0x80d83e2b9bf263279319d2c90f17c8b86ce0c774 去引入合約來使用,並非用我們最一開始的合約。

使用邏輯

CONTRACT 選中邏輯合約 UpgradeableWhiteListContract.sol,然後在 At Adress 輸入代理合約的位置,最下方就會出現新的一行,開啟後就是我們的邏輯合約可以用的 Function

再說一次很重要,這邊 At Adress 填入的是代理合約的地址,不可以直接使用我們部署的邏輯合約。

打開剛剛新增的合約,使用 SetWhiteList() 填入我們狐狸錢包的地址 及 _mintLimit:1,最後送出。

_mintLimit 參數輸入 1

*錢包地址往上拉可以看到 ACCOUNT,或者打開狐狸錢包目前選擇的當下錢包,可以直接複製地址;*這邊送出的內容紀錄可以在 Etherscan 查詢到。

過一段時間後使用 GetWhiteList(),填入剛剛 SetWhiteList() 填寫的地址,應該會看到自己已經在白名單內了。

取得參數

  1. 部署邏輯合約v2

在以往我們部署了合約就無法更動了,如果今天使用了可升級合約的方式,在我們修改合約後可以再度透過管理合約把代理合約指向的合約位置改掉。

先讓我們把邏輯合約做一下修改, GetWhiteList() 請在後面加上 +99

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";

contract UpgradeableWhiteListContract is Initializable, OwnableUpgradeable {

mapping(address => bool) private whiteList;
mapping(address => uint256) private mintLimit;

function initialize() public initializer {}

function SetWhiteList(address _address, uint256 _mintLimit) external {
whiteList[_address] = true;
mintLimit[_address] = _mintLimit;
}

function GetWhiteList(address _address) public view returns (bool, uint256){
return (whiteList[_address], mintLimit[_address] + 99);
}

// 使用ownerRestricted修饰符来限定只有合约的所有者才能调用该函数
function destructContract(address payable target) public payable {
selfdestruct(target);
}
}

然後一樣送出合約並且部署,這邊的合約我們簡稱 邏輯合約v2

邏輯合約v2

部署結束後一樣把地址記錄下來,這邊筆者測試地址為在 Rinkeby 測試網 的 0x56e4f75ed087cc0f437ecc0f8099933891ed7083Etherscan 傳送門

  1. 升級合約

剛剛部署完 邏輯合約v2,接下來就要透過管理合約讓代理合約知道它要指向的目標合約從 邏輯合約 改為 邏輯合約v2

升級合約

這邊使用剛開始不久部署 ProxyAdmin(管理合約)upgrade() ,填入相關參數,最後送出。

  • proxy:填入剛剛部署的代理合約地址 0x80d83e2b9bf263279319d2c90f17c8b86ce0c774
  • implementation:填入新的 邏輯合約v2 地址 0x56e4f75ed087cc0f437ecc0f8099933891ed7083

部署紀錄結果一樣可以在 Etherscan 查詢到

  1. 使用升級合約

過段時間等剛剛的動作上鏈後,再回去使用剛剛透過 At Address 帶入的代理合約的 GetWhiteList() 即可拿到 邏輯合約v2 返回的結果,這邊必須要特別注意,如果返回值是 99 就代表是使用了錯誤的合約,回傳的值必須是包含第一次我們操作結果的值,因為我們第一次操作 SetWhiteList() 的值是存在代理合約內,重新部署新的合約,並不會把我們變數的值給清掉。

再次取得參數


疑問點

因為先前沒有部署過任何可升級合約,目前也在研究這塊,有碰到幾個疑問點,希望能有前輩指導。

  1. 通常合約上鏈發布後,會把源碼公開在 Etherscan 上,這邊的話我應該公布哪個合約?邏輯合約?代理合約?邏輯合約v2?
  2. 代理合約需要初始化的 data_ 除了 0x8129fc1c 一直填入失敗,想知道為什麼,且這邊也無法留空。
  3. 邏輯合約部署上鏈後其實還是可以直接被呼叫的,正常變數的值都存在代理合約內,想知道普通升級後的做法是會怎麼單除處理邏輯合約呢?
  4. 正常部署了新版邏輯合約會把舊版刪除嗎?
  5. 為何還需要透過 ProxyAdmin 合約去更新代理合約指向的邏輯合約地址呢?明明透過 ProxyAdminupgrade() 也是回去呼叫代理合約的 upgradeTo() 更新 Function

Conclusion & 結論

這是一篇練習使用可升級合約的紀錄,途中還有很多不懂的,需要慢慢理解,另外練習用的合約已經透過 destructContract() 刪除掉,所以可以不用嘗試呼叫範例合約。

其實筆者一直認為與其叫升級合約不如叫替換合約,因為只是 Proxy 指向的地址換了,但最後想想,根據 openzeppelin 網站的升級限制條件指出,升級必須遵守幾點,所以把他稱為升級其實才是對的。

  1. 不能改變變數的類型
  2. 不能改變變數的順序
  3. 不能在現有變數引入先的變數
  4. 不能刪除現有變數

詳細條件可參考 Openzeppelin Upgrades Plugins - Modifying Your Contracts

其實合約最重要的就是嚴謹,還有反覆測試後,確定沒有太嚴重的邏輯錯誤,標題雖然寫著 合約有漏洞?要升級?要修改?怎麼辦? 但其實升級合約還是必須遵守上面那些條件。

另外合約裡面有設定 WhiteList,在下一篇我們將會講到怎麼設定白名單,以及目前大部分的人使用的設定方式,可以大量減少花費 ETH


參考網站