NFT 可升級智能合約

  • FrankFrank
  • /
  • 37 分鐘閱讀
  • /
  • Jun 19, 2022
  • /
  • - views

智能合約發佈到區塊鏈之後,原始碼無法再次進行更改,因此往往出現一種情況,就是智能合約發佈後,發現有 bug,或者需要加入其他功能時,則必須重新發佈新的合約,而且要將數據進行轉移,以及通知所有使用者使用新合約地址。這個過程非常麻煩。有沒有更好的方法?

答案是有的,Upgradable Contract 正式要解決這個問題。 Upgradable Contract 透過使用「代理 - 實現」 的模式,巧妙的做到了類似能夠更改合約內容的效果。

之所以能夠實現「代理」效果,這就要說說 EVM delegatecall這個功能。 delegatecall 可以簡單理解為將對方的程式碼「copy & paste」到自己當下的運行環境中執行,因為只是「copy & paste」程式碼,因此所有智能合約的狀態(state),都保留在調用delegatecall的合約中。因此,想要「升級合約」,只需要將delegatecall所指向的合約地址更改,即可做到宛如修改了合約內容一樣的效果。

使用 OpenZeppelin Upgradable Contract

強大的 OpenZeppelin 已經為我們準備好了所有可升級智能合約藍本。有兩種 Pattern 供我們選擇:

  • Transparent Proxy Pattern
  • UUPS Proxy (Universal Upgradeable Proxy Standard)

這兩種 Proxy Pattern,不同之處在於升級合約的邏輯放在哪裡,以及發佈所需要的 Gas 會有不同。詳細的比較,可以參考官方具體的解釋

本文我們先來介紹 Transparent Proxy Pattern , 這也是存在比較久的一種代理模式。

Transparent Proxy Pattern 的可升級合約,包括三個部分: 「代理合約」,「代理管理員合約 Proxy Admin」和「邏輯合約」。

代理合約 Proxy Contract

顧名思義,代理合約就像現實生活中的代理一樣,「代為處理」業務邏輯。代理合約面向用戶,用戶直接和代理合約進行交互。 在收到交互請求後,代理合約會將請求透過 delegatecall 調用「邏輯實現合約」內的程式碼進行處理。因為使用 delegatecall 的關係,所有的業務數據,都依然保存在代理合約中,這包括智能合約的 state 以及合約內的財產。每次使用 deployProxy 指令發佈合約,都會產生一個新的代理合約。

舉例,在任何程式碼都不修改的情況下,進行兩次 deployProxy 後結果如下:

% yarn hardhat run scripts/deploy_proxy.js --network rinkeby

Deploying Box...
0xA2aE342f98B3f5052CBD5EA008c6d2DC79d5EAeD  代理合約
0x5794FD1d38e7F8CF573189C8c0b6F5EE9D126E77  邏輯實現合約
0x388A177C570CB0b3A68B30D90db5f31DA9bbb225  管理合約
✨  Done in 43.61s.


% yarn hardhat run scripts/deploy_proxy.js --network rinkeby

Deploying Box...
0x55D4526AABeE01a2C6140c4e101f47C6AC07369B  代理合約
0x5794FD1d38e7F8CF573189C8c0b6F5EE9D126E77  邏輯實現合約
0x388A177C570CB0b3A68B30D90db5f31DA9bbb225  管理合約
✨  Done in 30.55s.

可以看到兩次發佈,只有代理合約被重新發佈了,而邏輯實現合約和管理合約都沒有改變。

代理管理員合約 Proxy Admin

Proxy Admin 合約是作為所有你所發佈的「代理合約」的管理員合所有者。Proxy Admin 合約可以對指定代理合約進行邏輯升級。值得留意的是,每個地址在每個網路上只會發佈一次 Proxy Admin 合約,Proxy Admin 合約的所有權可以透過調用 transferOwnership 來轉讓。

What is a proxy admin? A ProxyAdmin is a contract that acts as the owner of all your proxies. Only one per network gets deployed. When you start your project, the ProxyAdmin is owned by the deployer address, but you can transfer ownership of it by calling transferOwnership.

邏輯實現合約 Implementation Contract

和上面的代理合約對應,邏輯實現合約內包含真正的合約業務邏輯,被代理合約透過delegatecall 調用。邏輯實現合約可以發佈無數新版本,這也是我們能修改合約內容,升級合約的根源所在。但無論有多少個邏輯實現合約,同一時間代理合約只會指向一個邏輯實現合約。

User ---- tx ---> Proxy ----------> Implementation_v0
                     |
                      ------------> Implementation_v1
                     |
                      ------------> Implementation_v2

注意事項

使用 Transparent Proxy Pattern 的可升級智能合約,因為技術原因,必須滿足以下這些要求:

不能依賴 constructor 執行邏輯

可升級智能合約不會調用 constructor,因此寫在 constructor 內的邏輯將被忽略。

安全原因盡量不使用 delegatecall 和 selfdestruct

在使用可升級的智能合約時,用戶其實是在和代理合約交互。所有數據亦都是保存在代理合約內。 因此,正常情況下用戶不會與底層邏輯實現合約交互。但當然,惡意或者好奇的朋友依然可以直接調直接向邏輯合約發送交易,但正如上面所說,我們所有數據都是保存在代理合約內,所以邏輯合約狀態改變一般不會影響代理合約的運作。

然而,如果對邏輯合約因為被惡意調用selfdestruct 而消失了,那就會有問題了。這會導致代理合約指向一個沒有任何程式碼的「空合約」,這將完全破壞合約運作,後果不堪設想。

同樣的,如果邏輯合約中使用了 delegatecall, 變相有可能可以做到同樣效果。如果合約可以透過某種方式 delegatecall一個包含自毀selfdestruct的惡意合約,那麼我們的邏輯合約也會被銷毀。 因此,合約中不使用 selfdestruct 或 delegatecall,才能確保合約安全。

不可使用 immutable variables

immutable variables 是 Solidity 0.6.5 後新引入的變數 modifier。 immutable variables 和 constant variables 都有類似的效果,保證該變數不可二次賦值。兩者的區別是,constant variables 的初始賦值是在智能合約編譯時已經完成,而immutable variables 則可以在 constructor 執行時進行賦值操作。

Upgradable contract 不支援 constructor, 因此如果 immutable variables 有在 constructor 中進行任何操作則會導致嚴重問題,所以,Upgradable contract 不可使用 immutable variables。

不可使用 external libraries

正因為可升級智能合約對於其邏輯合約有一些列的寫法要求,我們無法保證 external libaries 中是否有違反可升級智能合約要求的寫法而導致嚴重的安全或邏輯問題。基於這個原因,官方建議不要使用 external libraries。

不可刪除現有變數,不可更改現有變數的類型,排列次序,不可在現有變數前新加入其他變數,以及不可在有繼承關係的父合約中新加入任何新的變數

以上這些關於變數的要求,都和智能合約的底層數據存儲有關,詳細原因可以參閱官方文檔

不可直接對非constant 的變數進行賦值

在定義變數的同時賦值,和在 constructor 中設定初始值效果一樣。上面已經一再強調,所有基於 constructor 而進行的操作都不受可升級智能合約支援。如果這樣寫,初始值不會被帶到代理合約中,變相會影響合約邏輯。

然而如果變數為 constant,則可以直接賦值,因為 constant 的賦值操作是在編譯 ( compile ) 時進行。

contract MyContract {
	uint256 public initialVal = 16; // 不可
	uint256 public constant initialValC = 16; // constant 可以
}

實戰

上面說了很多理論,下面我們就手把手建立一個可升級的 NFT 智能合約。

確保電腦已經安裝 NodeJS , yarn 等必備工具。

建立工作資料夾,安裝 Hardhat
mkdir upgradable-demo && cd upgradable-demo
yarn init -y
yarn add hardhat
yarn add dotenv
安裝 OpenZeppelin Contracts
yarn add @openzeppelin/contracts-upgradeable
安裝並設定 Hardhat
yarn hardhat

然後根據提示選擇 Create a basic sample project, 之後全部選擇 Y 。

完成後,安裝 @openzeppelin/hardhat-upgrades

yarn add @openzeppelin/hardhat-upgrades

完成後,當前路徑會產生一個 hardhat.config.js 檔案, 打開這個檔案,在頂部加入如下 import 語句:

// hardhat.config.js
require("@nomiclabs/hardhat-waffle");
require('@openzeppelin/hardhat-upgrades');
require('dotenv').config( { path: `.env.${process.env.NODE_ENV}` } )

撰寫可升級 NFT 智能合約

準備工作完成,現在我們開始撰寫智能合約。

mkdir ./contracts && cd ./contracts

在 contracts 資料夾中我們建立一個智能合約 NFT.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract FRANKNFT is Initializable, ERC721Upgradeable, ERC721EnumerableUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize( string memory _name, string memory _symbol ) initializer public {
        __ERC721_init(_name, _symbol);
        __ERC721Enumerable_init();
    }

    function _baseURI() internal pure override returns (string memory) {
        return "ipfs://Qma2uFDzG1aWrHYrtpwiXdh7xqf7wpxdXJ6osJV6TyEydJ/";
    }

    // The following functions are overrides required by Solidity.

    function _beforeTokenTransfer(address from, address to, uint256 tokenId)
        internal
        override(ERC721Upgradeable, ERC721EnumerableUpgradeable)
    {
        super._beforeTokenTransfer(from, to, tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721Upgradeable, ERC721EnumerableUpgradeable)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}
編譯智能合約:
% NODE_ENV=dev npx hardhat compile

Compiled 15 Solidity files successfully
發佈智能合約

現在我們可以發佈智能合約到 Goerli 測試網路進行測試。 (當然,正常情況下我們要先進行 unit test,以及發佈在 local node 上進行測試,本文暫時省略 local 測試步驟)

在當前路徑下建立一個 .env.dev 檔案,並加入下面的內容:

RCP_URL=https://rinkeby.infura.io/v3/<你的ProjectID>
PRIVATE_KEY=<錢包 Private Key>
ETHERSCAN_API_KEY=<ethersscan 的 API key>

至此我們需要設定一些必要的參數,RCP_URL 是 RCP 的 URL, 我們這裡使用 alchemyapi,可以在 infura.io 上免費註冊一個。PRIVATE_KEY 是用來發佈合約所使用錢包的私鑰, ETHERSCAN_API_KEY 則是為了在 etherscan 上驗證合約所需要的 API key,可以在 etherscan 上免費取得。

完成後,移步到 hardhat.config.js 檔案,加入網路設定:

module.exports = {
  solidity: "0.8.4",
  networks: {
    goerli: {
      url: process.env.RCP_URL,
      chainId: 5,
      gas: 15000000,
      accounts: (process.env.PRIVATE_KEY || '').split(' ')
    },
  }
};

完成設定後,我們就可以準備發佈合約了。 在 ./script 路徑下建立一個 deploy.js

// scripts/deploy.js

const { ethers } = require("hardhat");
const { upgrades } = require("hardhat");

async function main() {

  const NFT = await ethers.getContractFactory("FRANKNFT")
  
  console.log("正在發佈 FRANKNFT ...")
  const proxy = await upgrades.deployProxy(NFT, ["FRANKNFT","FRANK"], { initializer: 'initialize' })
  
  console.log("Proxy 合約地址", proxy.address)
  console.log("等待兩個網路確認 ... ")
  const receipt = await proxy.deployTransaction.wait(2);

  console.log("管理合約地址 getAdminAddress", await upgrades.erc1967.getAdminAddress(proxy.address))
  console.log("邏輯合約地址 getImplementationAddress", await upgrades.erc1967.getImplementationAddress(proxy.address))    
}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

完成後,便可以調用上面的 script 發佈我們的 NFT 智能合約了:

% NODE_ENV=dev yarn hardhat run scripts/deploy.js --network goerli

正在發佈 FRANKNFT ...
Proxy 合約地址 0xfA46A6Afd5a5925c1459Ee7D14fd680B022e234C
等待兩個網路確認 ... 
管理合約地址 getAdminAddress 0x97E276f42F587a6dFA8969845F0C83bFd649Add2
邏輯合約地址 getImplementationAddress 0xA8A837F1351FB8CA2961F8Fb952B49c940C0cF05
驗證智能合約

為了方便之後操作,下一步,我們在 etherscan 上驗證剛才發佈的邏輯合約:

yarn add @nomiclabs/hardhat-etherscan

hardhat.config.js 檔案,加入 etherscan 設定:

module.exports = {
  solidity: "0.8.4",
  networks: {
    goerli: {
      url: process.env.RCP_URL,
      chainId: 5,
      gas: 15000000,
      accounts: (process.env.PRIVATE_KEY || '').split(' ')
    },
  },
  etherscan: {
    // Your API key for Etherscan
    // Obtain one at https://etherscan.io/
    apiKey: {
      goerli: '<你自己的 key>'
    }
   }
};

然後運行:

NODE_ENV=dev yarn hardhat verify <剛才上面一步顯示的邏輯合約地址> --network goerli

Nothing to compile
Successfully submitted source code for contract
contracts/NFT.sol:FRANKNFT at 0xA8A837F1351FB8CA2961F8Fb952B49c940C0cF05
for verification on the block explorer. Waiting for verification result...

Successfully verified contract FRANKNFT on Etherscan.
https://goerli.etherscan.io/address/0xA8A837F1351FB8CA2961F8Fb952B49c940C0cF05#code
✨  Done in 34.51s.

完成後,來到 etherscan 上,就可以看到合約已經完成驗證了。

接下來,我們在 etherscan 上找到剛才發佈的 Proxy 合約地址,在 「More Option」中驗證 Proxy:

驗證完成後,會到 Proxy 合約,可以看到已經多出了「Read as Proxy」 和 「Write as Proxy」功能:

至此,我們已經完成了第一個版本的 NFT 發佈和驗證。

撰寫新版 NFT 智能合約

發佈合約後我們發現在 Version 1 的合約中忘記了加入 mint 功能。不急,現在我們加入 Version 2 的合約。

在 contracts 資料夾中我們建立一個智能合約 NFTV2.sol,加入 mint 的相關邏輯:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract FRANKNFTV2 is Initializable, ERC721Upgradeable, ERC721EnumerableUpgradeable {
    

    uint256 counter;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }
    
    function initialize( string memory _name, string memory _symbol ) initializer public {
        __ERC721_init(_name, _symbol);
        __ERC721Enumerable_init();
        counter = 0;
    }

    function _baseURI() internal pure override returns (string memory) {
        return "ipfs://Qma2uFDzG1aWrHYrtpwiXdh7xqf7wpxdXJ6osJV6TyEydJ/";
    }

    function mint() external  {
        counter = counter + 1;
        _safeMint(_msgSender(), counter);
    }



    // The following functions are overrides required by Solidity.

    function _beforeTokenTransfer(address from, address to, uint256 tokenId)
        internal
        override(ERC721Upgradeable, ERC721EnumerableUpgradeable)
    {
        super._beforeTokenTransfer(from, to, tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721Upgradeable, ERC721EnumerableUpgradeable)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

接著,在 ./script 中加入 upgrade.js:

const { ethers } = require("hardhat");
const { upgrades } = require("hardhat");

const proxyAddress = '0xfA46A6Afd5a5925c1459Ee7D14fd680B022e234C'

async function main() {

  console.log("指定的Proxy 合約地址", proxyAddress)

  const NFTV2 = await ethers.getContractFactory("FRANKNFTV2")
  console.log("升級合約進行中...")

  const proxy = await upgrades.upgradeProxy(proxyAddress, NFTV2)
  console.log("Proxy 合約地址", proxy.address)

  console.log("等待2個網路確認 ... ")
  const receipt = await proxy.deployTransaction.wait(2);
  
  console.log("管理合約地址 getAdminAddress", await upgrades.erc1967.getAdminAddress(proxy.address))  
  console.log("邏輯合約地址 getImplementationAddress", await upgrades.erc1967.getImplementationAddress(proxy.address))

}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

執行升級:

% NODE_ENV=dev yarn hardhat run scripts/upgrade.js --network goerli
yarn run v1.22.4


指定的Proxy 合約地址 0xfA46A6Afd5a5925c1459Ee7D14fd680B022e234C
升級合約進行中...
Proxy 合約地址 0xfA46A6Afd5a5925c1459Ee7D14fd680B022e234C
等待2個網路確認 ... 
管理合約地址 getAdminAddress 0x97E276f42F587a6dFA8969845F0C83bFd649Add2
邏輯合約地址 getImplementationAddress 0xFc069d144840644c666f912b5b892aB1Afba59b8

✨  Done in 66.63s.

可以看到,升級完成後,管理合約地址Proxy 合約地址都沒有改變,而邏輯合約地址則改變為最新的合約地址。

驗證新版本合約:

NODE_ENV=dev yarn hardhat verify <剛才上面一步顯示的新邏輯合約地址> --network goerli

來到 etherscan,看到合約已經多了 mint 功能。

調用該功能後,我們成功 mint 到了 NFT。 可喜可賀。

有興趣的讀者可以去 etherscan 上了解詳情