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 package allows for easy deployment of IPFS-hosted NFT metadata.

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

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 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:


Replace your hardhat.config.js with the following:


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
    "apiKey": process.env.DEFENDER_KEY,
    "apiSecret": process.env.DEFENDER_SECRET

Upload NFT Metadata

This NFT token will consist of an image. The 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 ""
import fs from 'fs'
import dotenv from 'dotenv'

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

   .then(() => process.exit(0))
   .catch((error) => {

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

  .then(() => process.exit(0))
  .catch((error) => {

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)

   .then(() => process.exit(0))
   .catch((error) => {

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:{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.

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

  .then(() => process.exit(0))
  .catch((error) => {

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


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

  .then(() => process.exit(0))
  .catch(error => {

Next, run the proposal:

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

Compiled 1 Solidity file successfully

Preparing proposal...

Upgrade proposal created at:

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, 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.