Deploy and Manage a UUPS-Upgradeable NFT Contract

This guide shows you how to deploy a UUPS upgradeable ERC721 contract for minting NFTs. Ownership will be transferred to a Gnosis Safe multisig account and contract administration will be managed using OpenZeppelin Defender.

Development Environment Setup

For this tutorial, you will use Hardhat as a local development environment using node package manager. To get started, run the following:

$ mkdir uupsNFT

$ npm init -y

$ npm i dotenv

$ npm i --save-dev hardhat @nomiclabs/hardhat-etherscan

$ npx hardhat

Select Create a basic sample project and accept the default arguments.

You will need to install the OpenZeppelin Upgradeable Contracts library as well as the Hardhat Defender npm package for integrating upgrades with OpenZeppelin Defender. The Upgradeable Contracts package replicates the structure of the main OpenZeppelin Contracts, but with the addition of the Upgradeable suffix for every file and contract.

The nft.storage package allows for easy deployment of IPFS-hosted NFT metadata.

$ npm i --save-dev @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-defender @openzeppelin/hardhat-upgrades nft.storage

The sample project creates a few example files that are safe to delete:

$ rm scripts/sample-script.js test/sample-test.js contracts/Greeter.sol

The dotenv package allows you to access environment variables stored in a local file, but that file needs to be created:

$ touch .env

Other Setup

You will need to obtain a few important keys to be stored in your .env file. (Double-check that this file is listed in your .gitignore so that your private keys remain private.)

In Alchemy, create an app on Rinkeby and copy the http key. Add it to your .env file.

In Metamask, click Account Details -> Export Private Key to copy the private key you will use to deploy contracts.

Important Security Note: Use an entirely different browser and a different Metamask account than one you might use for other purposes. That way, if you accidentally reveal your private key, the security of your personal funds will not be compromised.

Get an API key from nft.storage and add it to your .env file.

The contract will initially be deployed with a single EOA. After an initial upgrade, ownership will be transferred to a Gnosis Safe multisig.

To create a new Gnosis Safe in OpenZeppelin Defender, navigate to Admin, select Contracts → Create Gnosis Safe. Provide the addresses of three owners and set the threshold at two for the multisig.

You can create a new API key and secret in Defender by navigating to the hamburger menu at the top right and selecting Team API Keys. You can select yes for each of the default options and click Save.

Your .env will look something like this:

PRIVATE_KEY=
ALCHEMY_URL=
ETHERSCAN_API=
DEFENDER_KEY=
DEFENDER_SECRET=
NFT_API=

Replace your hardhat.config.js with the following:

require("@openzeppelin/hardhat-upgrades");
require("@nomiclabs/hardhat-etherscan");
require('@nomiclabs/hardhat-waffle');
require('@openzeppelin/hardhat-defender');
require('dotenv').config();

module.exports = {
  solidity: "0.8.4",
  networks: {
    rinkeby: {
      url: `${process.env.ALCHEMY_URL}`,
      accounts: [`0x${process.env.PRIVATE_KEY}`],
    } 
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API
  },
  defender: 
  {
    "apiKey": process.env.DEFENDER_KEY,
    "apiSecret": process.env.DEFENDER_SECRET
  }
  
};

Upload NFT Metadata

This NFT token will consist of an image. The nft.storage npm package gives developers a straightforward way of uploading the .json metadata as well as the image asset.

From the base project directory, create a folder to store the image asset:

$ mkdir assets

$ touch scripts/uploadNFTData.mjs

Include the following code in this file, updating the code as necessary to use your image asset, name, and description:

import { NFTStorage, File } from "nft.storage"
import fs from 'fs'
import dotenv from 'dotenv'
dotenv.config()

async function storeAsset() {
   const client = new NFTStorage({ token: process.env.NFT_KEY })
   const metadata = await client.store({
       name: 'MyToken',
       description: 'This is a two-dimensional representation of a four-dimensional cube',
       image: new File(
           [await fs.promises.readFile('assets/cube.gif')],
           'cube.gif',
           { type: 'image/gif' }
       ),
   })
   console.log("Metadata stored on Filecoin and IPFS with URL:", metadata.url)
}

storeAsset()
   .then(() => process.exit(0))
   .catch((error) => {
       console.error(error);
       process.exit(1);
   });

Run the script:


$ node scripts/uploadNFTData.mjs

Metadata stored on Filecoin and IPFS with URL: ipfs://bafyreidb6v2ilmlhg2sznfb4cxdd5urdmxhks3bu4yqqmvbzdkatopr3nq/metadata.json

Success! Now your image and metadata are ready to be linked to your NFT contract.

Create Smart Contract

Go to wizard.openzeppelin.com and select ERC721.

Give your token whatever features you would like. Be sure to check the box for Upgradeability and select UUPS.

Select Download → As Single File. Save this in your project’s /contracts folder.

Note the Solidity version in the contract and edit hardhat.config.js to either match this version or be more recent.

Initial Deployment

Create a file and supply the following code, adjusting as necessary based on your contract and token name:


$ touch scripts/deploy.js

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

async function main() {

  const CubeToken = await ethers.getContractFactory("CubeToken");
  const cubeToken = await upgrades.deployProxy(CubeToken);
  await cubeToken.deployed();

  console.log("Token address:", cubeToken.address);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Run the script:

$ npx hardhat run scripts/deploy.js --network rinkeby

Token address: 0x12a9ba92b3B2746f41AcC45Af36c44ac00E107b0

Mint NFT

Now that the contract is deployed, you can call the safeMint function to mint the NFT using the data uploaded to IFPS earlier.

Create a new file to include the following commands, substituting the address of your proxy contract and metadata URL in the relevant sections.

$ touch scripts/mintToken.mjs


const CONTRACT_ADDRESS = "0x12a9ba92b3B2746f41AcC45Af36c44ac00E107b0"
const META_DATA_URL = "ipfs://bafyreidb6v2ilmlhg2sznfb4cxdd5urdmxhks3bu4yqqmvbzdkatopr3nq/metadata.json"

async function mintNFT(contractAddress, metaDataURL) {
   const ExampleNFT = await ethers.getContractFactory("CubeToken")
   const [owner] = await ethers.getSigners()
   await ExampleNFT.attach(contractAddress).safeMint(owner.address, metaDataURL)
   console.log("NFT minted to: ", owner.address)
}

mintNFT(CONTRACT_ADDRESS, META_DATA_URL)
   .then(() => process.exit(0))
   .catch((error) => {
       console.error(error);
       process.exit(1);
   });

Run the script:

$ npx hardhat run scripts/mintToken.mjs

NFT minted to: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

Verify Contract

Until this point, we have been able to run functions on the deployed contract because we have the Application Binary Interface (ABI). We will need the ABI to run contract adminstration in Defender. Fortunately, Defender is able to automatically import the contract ABI for contracts that have been verified.

Go to the contract’s address in Etherscan and select Read as Proxy:

https://rinkeby.etherscan.io/address/{PROXY_ADDRESS}

Click Verify.

Copy the implementation address.

Use this address from the command line to verify the smart contract of the implementation.


$ npx hardhat verify --network rinkeby 0xf92d88cbfac9e20ab3cf05f6064d213a3468cf77

Nothing to compile

Successfully submitted source code for contract

contracts/MyToken.sol:MyToken at 0xf92d88cbfac9e20ab3cf05f6064d213a3468cf77

for verification on the block explorer. Waiting for verification result...

Successfully verified contract MyToken on Etherscan.

https://rinkeby.etherscan.io/address/0xf92d88cbfac9e20ab3cf05f6064d213a3468cf77#code

Defender Contract Admin

Defender’s Admin feature makes it easy to manage contract administration and call contract functions. To do this, the contract needs to be imported into Defender.

In Defender, navigate to Admin --> Add Contract --> Import Contract.

Give it a name, select Rinkeby, and paste your contract’s proxy address from Etherscan.

Defender will detect that the contract is Upgradable and Pausable.

Select Add.

Deploy Version 2 using Hardhat

Edit the smart contract’s code, adding the following at the very end of the file:


contract MyTokenUpgraded is MyToken {
  function version() pure public returns(uint){
  return 2;
  }
}

Because this contract inherits the previously deployed one, it contains the existing functionality plus this function just added.

Next, create a script to deploy the new implementation contract:

$ touch scripts/upgrade.js

Add the following, substituting the proxy address:


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

async function main() {

  const CubeTokenUpg = await ethers.getContractFactory("CubeTokenUpgraded");

  const cubeTokenUpg = await upgrades.prepareUpgrade("0x...", CubeTokenUpg);

  
  console.log("Upgrade Implementation address:", cubeTokenUpg);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

The prepareUpgrade function both validates and deploys the implementation contract.

Run the script to deploy the upgraded contract:

$ npx hardhat run scripts/upgrade.js --network rinkeby

0xB463054DDa7a0BD059f1Ba59Fa07Ebd7f531E9d7

Upgrade Proxy via Defender

The upgraded implementation has been deployed, but the proxy currently still points to the initial version.

You can upgrade the contract using Defender by selecting New Proposal --> Upgrade.

Paste in the address you just got for the new implementation. Since the contract is still owned by your Metamask account, and that account is connected to Defender, you will leave the admin contract and admin address blank.

Give the upgrade a friendly name and (optionally) a description, then select Create Upgrade Proposal.

On the next screen, review the details and execute the upgrade.

Now, under the Admin dashboard for the contract, you will see the upgrade listed. Selecting it from the dashboard will take you back to the account details for that transaction.

Transfer Ownership to Multisig

It is not very secure for the owner of a smart contract to be a single account in Metamask. As a next step, transfer ownership to the multisig created earlier.

Under Admin, select New Proposal → Admin Action.

One function of the Ownable contract is transferOwnership, which does what you would expect.To execute it via Defender Admin, simply select the it from the Function dropdown. The function takes the parameter of the new owner’s address. For that, select the name of the Gnosis Safe Multisig you created earlier.

For this function, the Execution Strategy is still EOA. Give the admin action proposal a name, select Create Admin Action, and then execute the transaction using your connected Metamask account.

After completing this step, the contract’s new owner is the multisig, so future transactions will require two approvals.

Propose a New Upgrade using Hardhat

Using hardhat-defender, you can propose an upgrade to a contract owned by another account. This creates a proposal for review in Defender.

You will need to create a script similar to before.


$ touch propose-upgrade.js


const { defender } = require("hardhat");

async function main() {
  const proxyAddress = '{{INSERT_YOUR_PROXY_ADDRESS}}';
  const CubeTokenV3 = await ethers.getContractFactory("CubeTokenV3");

  console.log("Preparing proposal...");

  const proposal = await defender.proposeUpgrade(proxyAddress, 
  CubeTokenV3, {title: 'Propose Upgrade to V3', multisig: '{{YOUR MULTISIG ADDRESS}}' });
  console.log("Upgrade proposal created at:", proposal.url);
}

main()
  .then(() => process.exit(0))
  .catch(error => {
  console.error(error);
  process.exit(1);
})

Next, run the proposal:

$ npx hardhat run scripts/propose-upgrade.js --network rinkeby

Compiled 1 Solidity file successfully

Preparing proposal...

Upgrade proposal created at: https://defender.openzeppelin.com/#/admin/contracts/rinkeby-0x98A28EdD77Ba4249D85cbe9C902c92b037C6b977/proposals/2a577119-ab7b-4ab8-837d-b81acccc2684

Clicking the link will take you to the proposal review page in Defender where you can choose to Approve and Execute, if desired.

Check Out the NFT

You minted an NFT token in an earlier step. By now, it should be ready to view in OpenSea’s explorer. Head to testnets.opensea.io, select the dropdown, and enter the proxy contract address. Rather than hitting enter, it is necessary to click the link to the proxy contract address in the dropdown.

Congratulations! You have successfully used OpenZeppelin Defender and Hardhat to deploy an UUPS-upgradeable ERC721 NFT contract, transferred ownership to a Gnosis Safe Multisig, and deployed an upgraded implementation contract.

Resources

3 Likes

Thanks for sharing your tutorial. I would like to ask a question.

Using this piece of code you provided, you were able to upgrade the smart contract from a version 1 to the version 2.

contract MyTokenUpgraded is MyToken {
  function version() pure public returns(uint){
  return 2;
  }
}

What if I want to deploy a version 3, keeping version 2 changes?
Would I have to do something like this?

contract MyTokenUpgradedV3 is MyTokenUpgraded {
  function version() pure public returns(uint){
  return 3;
  }

   function doSomethingElse() public returns (uint) {
   /// a functiona that returns something else 
}
}

I'm looking for a way to implement some good practices when upgrading.
I don't know if it's better to make a next version that inherits the last version, or detach each version from the previous and each next version only inherits the base contract.
It seems to me that inheriting the previous version makes it harder to cause storage conflicts, but it will be harder to maintain functions across different implementations.

Opposed to the proposed method above, I would deploy a v3 implementation as follwoing:

contract MyTokenUpgradedV3 is MyToken {
   uint256 newState;

  function version() pure public returns(uint){
  return 3;
  }

   function doSomethingElse() public returns (uint) {
   /// a functiona that returns something else 
}
}

Then, the newStatevariable would need to be implemented into the next versions in the same position, to avoid storage conflict as far as I've understood:

contract MyTokenUpgradedV4 is MyToken {
   uint256 newState;
   uint256 V4State;


  function version() pure public returns(uint){
  return 4;
  }

   function doSomethingElse() public returns (uint) {
   /// a functiona that returns something else 
}
}
1 Like

Hi and welcome to the community! Great post, by the way.

It would not be necessary to inherit the previous version in all cases. The upgrade pattern you suggested where you inherit only from the base contract and implement all existing state variables will prevent storage conflicts. There is flexibility with respect to adding, removing, or modifying functions. Because of this, the important aspect of UUPS implementations, as I am sure you are aware, is to remember to include the ability to upgrade in each new implementation.

Hope this helps! Feel free to ask any other questions that might be useful.

All right, so I just need to be sure to add the next variables in the next implementation version in the exact same order.

What about the problems when accidentally causing storage collision? Can it be fixed in the next implementation?

What about giving the upgrade authorization to a DAO? Is there already a pattern for that?

If you're using OpenZeppelin's upgradeable contracts, this scenario wouldn't happen. :wink: See here for more information.

In this case, it would simply be a matter of transferring ownership or assigning a role to the relevant address, which would depend on how the DAO was structured. See this guide for more details on governance.