NFT 可升級智能合約 UUPS

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

上文已經詳細講過可升級智能合約的原理,注意事項,以及使用 Transparent Proxy 製作一個 NFT 智能合約,本文就繼續講講 UUPS Proxy。

Transparent Proxy 和 UUPS Proxy

Transparent Proxy 和 UUPS Proxy 其實究其根本實現的基礎都是一樣的, 都是使用 delegatecall 將邏輯剝離主合約。

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

Transparent Proxy 是一個較早的 Proxy Pattern, 而 UUPS 則是較遲才推出的 Pattern,目前官方比較建議使用 UUPS 。 兩者最明顯的差異是升級邏輯放在哪裡。

While both of these share the same interface for upgrades, in UUPS proxies the upgrade is handled by the implementation, and can eventually be removed. Transparent proxies, on the other hand, include the upgrade and admin logic in the proxy itself. This means TransparentUpgradeableProxy is more expensive to deploy than what is possible with UUPS proxies.

從上面的官方描述可以得知,UUPS Proxy 的升級邏輯是由邏輯合約來執行,不像 Transparent Proxy,需要部署額外的 Admin 合約,以及升級的邏輯由 Proxy 合約來完成。 因此, UUPS Proxy 合約的部署成本會低於 Transparent Proxy,而且因為升級是由邏輯合約控制,我們甚至可以隨時終止合約的「可升級性」。

實戰

UUPS Proxy 可升級合約,和 Transparent Proxy 可升級合約在程式碼上其實沒有太大的不同,下面的例子我也是偷懶將上文的 Transparent Proxy 合約的實戰部分抄過來,稍作修改變為 UUPS Proxy 可升級合約 。

最明顯的不同是:

  1. 邏輯合約需要繼承 OZ UUPSUpgradeable
  2. 邏輯合約需要 Override _authorizeUpgrade function;

OZ UUPSUpgradeable提供了升級合約的必要邏輯, _authorizeUpgrade 則用於進行權限控制,會在執行 upgradeToupgradeToAndCall時被調用,用於指定誰可以進行合約升級操作。 如果未經授權的地址,這個 function 需要進行 revert。

廢話不多說,現在我們就來製作 UUPS 可升級 NFT 合約。

建立工作資料夾,安裝 Hardhat
mkdir upgradable-demo-uups && cd upgradable-demo-uups
yarn init -y
yarn add dotenv hardhat
安裝 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 資料夾中我們建立一個智能合約 NFTUUPS.sol:

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

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


contract FRANKNFTUUPS is Initializable, ERC721Upgradeable, ERC721EnumerableUpgradeable,  OwnableUpgradeable, UUPSUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }
    
    function initialize( string memory _name, string memory _symbol ) initializer public {
        __ERC721_init(_name, _symbol);
        __Ownable_init();
        __UUPSUpgradeable_init();
        __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);
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}
}
編譯智能合約:
% 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-uups.js

留意,相比 Transparent Proxy , 我們在調用 upgrades.deployProxy 時需要附加一個參數kind: 'uups' 以作指定 UUPS Proxy 之用。

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

async function main() {

  const NFT = await ethers.getContractFactory("FRANKNFTUUPS")
  
  console.log("正在發佈 FRANKNFTUUPS ...")
  const proxy = await upgrades.deployProxy(NFT, ["FRANKNFTUUPS","FRANK"], { initializer: 'initialize', kind: 'uups' })
  
  console.log("Proxy 合約地址", proxy.address)
  console.log("等待兩個網路確認 ... ")
  const receipt = await proxy.deployTransaction.wait(2);
  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-uups.js --network goerli

正在發佈 FRANKNFTUUPS ...
Proxy 合約地址 xxxxx
等待兩個網路確認 ... 
邏輯合約地址 getImplementationAddress xxxxx
✨  Done in 33.51s.
驗證智能合約

為了方便之後操作,下一步,我們在 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 xxxxx
for verification on the block explorer. Waiting for verification result...

Successfully verified contract FRANKNFT on Etherscan.
https://goerli.etherscan.io/address/xxxxx#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 資料夾中我們建立一個智能合約 NFTUUPSV2.sol,加入 mint 的相關邏輯:

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

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


contract FRANKNFTUUPSV2 is Initializable, ERC721Upgradeable, ERC721EnumerableUpgradeable,  OwnableUpgradeable, UUPSUpgradeable {
    
    uint256 counter;
    
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }
    
    function initialize( string memory _name, string memory _symbol ) initializer public {
        __ERC721_init(_name, _symbol);
        __Ownable_init();
        __UUPSUpgradeable_init();
        __ERC721Enumerable_init();
    }

    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);
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}
}

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

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

const proxyAddress = '0xFC01Bd85cfe13AC20a84F81d6285CFb4212A6696'

async function main() {

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

  const NFTV2 = await ethers.getContractFactory("FRANKNFTUUPSV2")
  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("邏輯合約地址 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


Compiled 1 Solidity file successfully
指定的 UUPS Proxy 合約地址 XXX
升級合約進行中...
Proxy 合約地址 XXX
等待2個網路確認 ... 
邏輯合約地址 getImplementationAddress XXX

✨  Done in 57.30s.

驗證新版本合約:

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

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