Immutability—the characteristic of being unable to be changed or erased—is one of the key features of smart contracts. It is a major part of the security of Web3. But as every developer knows, sometimes a protocol needs an upgrade or a new feature. And—on rare occasion, of course—a developer might make a mistake. In such cases, it can be handy to be able to perform upgrades. This tutorial explains how to create an upgradeable NFT contract in accordance with EIP-1967 - Standard Proxy Storage Slots Universal Upgradeable Proxy Standard (UUPS).
Types of Upgradeable Smart Contracts
Smart contracts can be upgraded by separating a given smart contract into essentially two components: a proxy and an implementation. The diagram below symbolizes an upgrade from the contract ERC20 to the contract ERC20v2.
OpenZeppelin Contracts provide two options for proxy-based contract upgradeability: transparent proxies and universal upgradeable proxies. These are discussed briefly below. For a deep dive on the subject of smart contract upgradeability, see this blog post by Santiago Palladino.
Transparent
One type of smart contract upgrade follows what is called the transparent pattern. This pattern places both the state and the ability to upgrade in a proxy contract. The proxy points to a given implementation contract which holds the logic. When any non-admin address calls the contract, the calls are delegated to the implementation contract. When the admin calls the contract, the proxy reveals the upgradeTo function, giving the administrator the ability to point to a new implementation contract and thereby upgrade.
One downside to this proxy pattern is that interacting with it costs a lot of gas, because doing so requires two storage accesses to check if the caller’s address is the admin.
UUPS
The UUPS upgradeability pattern, on the other hand, is more efficient. This pattern places the upgrade function in the implementation contract. The proxy contract is minimal; it uses delegatecall to direct the implementation contract to execute transactions.
With this proxy pattern, it is vital to ensure that the upgrade function remains functional and present with each upgrade. Otherwise, the ability to upgrade will be lost forever.
This tutorial will follow the UUPS pattern.
Upgradeable Contract Deployment
This guide shows you how to deploy a barebones UUPS upgradeable ERC721 contract for minting gas-efficient 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('@openzeppelin/hardhat-defender');
require("@nomiclabs/hardhat-etherscan");
require('@nomiclabs/hardhat-waffle');
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). Verifying the source code makes the complete contract code and ABI available publicly. This is good practice and it also makes it easier to interact with the contract.
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. Importing a contract does not affect contract ownership in any way—and it can be done just as easily with a contract that you did not deploy as with one you did. For a simple example, you could add the Art Blocks NFT contract to your Admin dashboard and easily monitor contract state as well as do more interesting things such as firing a Sentinel notification each time a new NFT is minted.
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("{{YOUR_PROXY_ADDRESS}}", 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
There are many possible reasons why it can be beneficial for a smart contract to be upgradeable. What if your auditor (or a malicious hacker or bot) discovers a bug in your contract? A non-upgradeable contract would be without an elegant solution. Another example is more common to iterative development generally – contract upgradeability gives the contract owner the option to add, modify, or remove functionality as desired. There are other caveats to be aware of with respect to upgrading a contract. Existing storage variables cannot be changed, and any new variables that you declare must be added after the existing variables. With respect to functions, you have more flexibility. Functions can be added, modified or removed in a new upgrade. Be particularly attentive that you do not accidentally impede with the contract’s upgrade function, or else the contract will forever lose its ability to be upgraded.
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
- OpenZeppelin Defender
- OpenZeppelin Contracts
- Hardhat Upgrades NPM Package
- Hardhat Defender NPM Package
- nft.storage NPM Package
Top comments (0)