建立可升級的代理合約 以ERC20為例 Upgradable Contract / Proxy Contract

FengBro
12 min readJan 14, 2021

--

Photo by Kelly Sikkema on Unsplash

你將會在這篇文章看到

  • 甚麼是升級合約
  • 可升級合約的執行過程
  • 建立ERC20的可升級合約

什麼是可升級合約(Upgradable Contract)?

顧名思義,就是可以升級的合約。(被打)

一般來說,區塊鏈最令人耳熟能詳的就是不可竄改性,任何程式碼只要上鏈了就不能夠更改了,這賦予了區塊鏈最強大的功能,然後反面過來思考就是,萬一你個合約寫壞了,你也沒有辦法去更改,這不符合軟體產業快速迭代的特性了,可升級合約就是為了解決此問題,以下我們會介紹這跟一般合約有甚麼不同,接著會教學建立的步驟。

合約架構

可升級合約就是利用代理合約去實現升級的效果,如下圖所示,我們把一張合約分拆成 Proxy ContractLogic Contract ,將資料存在代理合約和程式邏輯儲存在邏輯合約中,所以升級的時候,舊有的資料並不會消失,而是會繼續保留在合約中,而抽象的邏輯就可以隨著升級的合約更新。

來源: https://blog.openzeppelin.com/proxy-patterns/

以上是最簡單的代理合約模型,你點進去來源網址會發現,實際上的代理合約模式是更複雜的。但在合約的架構上可以分為三種,當你第一次佈署代理合約的時候就會發現,共有三個合約被佈署,分別是代理合約管理員 Proxy Admin 、可升級代理合約 Upgradeability Proxy 、實例合約 Implementation Contract ,以下分別介紹:

  • 實例合約 Implementation Contract : 可被升級邏輯合約,可以藉由每次佈署不同的合約達到改變邏輯的效果,要注意的是變數等儲存資訊是不能被改動的,會導致合約崩潰。
  • 代理合約管理員 Proxy Admin : 儲存代理合約的擁有者,只有擁有者才能升級合約,並且在升級的時候呼叫 Upgradeability Proxy 更新 Implementation Contract 的地址。
  • 可升級代理合約 Upgradeability Proxy : 代理合約本人,地址永遠不變,所有使用者直接對該合約進行操作,會儲存 Implementation Contract 的地址。

代理合約跟一般合約的不同點

solidity中的constructor 並不是 runtime bytecode 的一部分,只會在佈署的過程中運行一次,所以代理合約無法使用實例合約的constructor,因為已經在佈署時運行過了,因此我們把要把實例合約的的程式碼移到 initialize function 中,如此就不會被solidity限制。

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/upgrades/contracts/Initializable.sol";contract MyContract is Initializable {
uint256 public x;
function initialize(uint256 _x) public initializer {
x = _x;
}
}

還有一個不同的地方,Solidity會自動啟動其他父層合約的constructor,但在 initializer 的狀況中,你需要手動處理。

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/upgrades/contracts/Initializable.sol";contract BaseContract is Initializable {
uint256 public y;
function initialize() public initializer {
y = 42;
}
}
contract MyContract is BaseContract {
uint256 public x;
function initialize(uint256 _x) public initializer {
BaseContract.initialize(); // Do not forget this call!
x = _x;
}
}

初始值跟constructor一樣只有deploy時有作用,因此要將值放在 initialize

// 正確
contract MyContract is Initializable {
uint256 public hasInitialValue;
function initialize() public initializer {
hasInitialValue = 42; // set initial value in initializer
}
}
//錯誤
contract MyContract {
uint256 public hasInitialValue = 42;
function initialize() public initializer {
}
}

佈署過程

佈署代理合約的過程很繁瑣,所以我們採用 openzeppelin-upgrades 的外掛插件,這個外掛會把複雜的佈署一次處理完畢,以下來介紹這個外掛做了甚麼事情。

佈署合約時要使用 deployProxy

  1. 確認合約是安全的 (upgrade safe)
  2. 佈署實例合約 Implementation Contract
  3. 佈署代理合約管理員 Proxy Admin
  4. 初始化實例合約 Implementation Contract
  5. 佈署可升級代理合約 Upgradeability Proxy

注意: 以上步驟是我看完原始碼執行跟合約佈署狀態後理解的順序,但跟官方文件的順序不同,大家可以一起研究指正。

升級合約要使用upgradeProxy

  1. 取得 proxy admin 權限,必須要是管理員才能升級合約
  2. 確認合約是安全的 (upgrade safe )
  3. 確認實例合約是不是有被佈署過,沒有再進行佈署
  4. 佈署要升級的實例合約
  5. 呼叫 Proxy Admin 合約,更新代理合約上的實例合約地址

補充: 如果Implementation Contract 的程式碼沒有改變,但又佈署一次 proxy 的話,則 impl. contact不會再被deploy,僅會佈署proxy contract。

https://github.com/OpenZeppelin/openzeppelin-upgrades

佈署 ERC20 代理合約

接下來我們就開始運行我們的程式碼吧,環境使用hardhat。

安裝hardhat,選擇建立空的config

$ npm install --save-dev hardhat$ npx hardhat
Welcome to Hardhat v2.0.2
✔ What do you want to do? · Create an empty hardhat.config.js
Config file created

用hardhat建鏈 (我個人是習慣用ganache)

$ npx hardhat node

設定 hardhat.config.js ,根據你的網路設定調整,可參考文件

/**  
* @type import('hardhat/config').HardhatUserConfig
*/
require('@nomiclabs/hardhat-ethers');
require('@openzeppelin/hardhat-upgrades');
module.exports = {
defaultNetwork: "ganache",
networks: {
ganache: {
url: "http://172.17.144.1:7545",
// accounts: [privateKey1, privateKey2, ...]
}
},
solidity: {
version: "0.6.12",
},
};

建立合約

// SPDX-License-Identifier: MIT 
pragma solidity >=0.6.0 <0.7.5;
import "@openzeppelin/contracts-upgradeable/proxy/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
contract TestToken is Initializable, ERC20Upgradeable {
function initialize(string memory name_, string memory symbol_, uint256 initialSupply) public virtual initializer {
__ERC20_init(name_, symbol_);
_mint(msg.sender, initialSupply);
}
}

建立佈署合約程式碼

const { ethers, upgrades } = require("hardhat");async function main() {
const TestToken = await ethers.getContractFactory("TestToken");
const testToken = await upgrades.deployProxy(TestToken, ['TestToken', 'TST', 100000000000]);
await testToken.deployed();
console.log("testToken deployed to:", testToken.address);
}
main();

佈署合約

npx hardhat run ./scripts/erc20-deploy-proxy.js

取得合約資訊,記得把地址改為生成的合約地址

const { BigNumber } = require("ethers");
const { ethers, upgrades } = require("hardhat");
async function main() {
const address = "0x8675Cfe9ef7815f43E08e87cda8438F5D7AAF5Fe";
const TestToken = await ethers.getContractFactory("TestToken");
const testToken = await TestToken.attach(address);
var totalSupply = await testToken.totalSupply();
console.log("testToken totalSupply:", totalSupply.toString());
const balances = ["0xF89fA5bC76F5C945FAb248bb50fDA846774a9BF9", "0xEd5aa8E471D012e18BeF2A35ADE4501d7Afe51c6", "0x2B2443067B14B989B488012cBb147b68EaC02891"];
balances.forEach((account, i) => {
var qqq = testToken.balanceOf(account).then(value => {
console.log("account", i, "balance: ", value.toString())
return value
});
});
}
main()
.then()
.catch(error => {
console.error(error);
process.exit(1);
});

其他操作可以參考我的github : https://github.com/cfengliu/upgradable-contract

補充: 儲存的問題

  1. 實例合約的地址存在哪?
  2. 實例合約的變數存在哪?

Ans:
1. 存在代理合約: https://github.com/OpenZeppelin/openzeppelin-upgrades/blob/6ffc421f0db0c8ab5dad19b978e50f59aa6ef1b9/packages/core/contracts/proxy/UpgradeabilityProxy.sol#L69

2. 會存在代理合約上:
因為使用delegatecall的關係,代理合約 storage slot 會儲存變數的值,實例合約的變數會指到proxy合約的變數。

如果你喜歡這篇文章,請幫我拍拍手,你的支持是我繼續寫的動力。

--

--