Skip to main content

How to Create and Deploy an Upgradeable Smart Contract using OpenZeppelin and Hardhat

Updated on
Sep 15, 2025

22 min read

Overview

One hard rule about developing on EVM blockchains is that any smart contracts that are deployed cannot be altered. Smart contracts are often called "immutable" which ensures that the code that developers are interacting with is tamper-proof and transparent. This philosophy is beneficial to those interacting with smart contracts but not always to those writing them. Developers writing smart contracts must always ensure that it is all-encompassing, error-free, and covers every edge case. This is often the case, but not always, and that is where the need for upgradeable smart contracts arises.

Using the upgradeable smart contract approach, if there is an error, faulty logic or a missing feature in your contract, a developer has the option to upgrade this smart contract and deploy a new one to be used instead.

In this tutorial, we will demonstrate exactly how this is done by creating and deploying an upgradeable smart contract from scratch using OpenZeppelin and Hardhat.

What We Will Do


  • Create an upgradeable smart contract using OpenZeppelin’s Hardhat Upgrades plugin
  • Compile and deploy the contract on Polygon PoS (Amoy testnet) using Hardhat 3
  • Verify the proxy and implementation on Polygonscan (Amoy)
  • Upgrade the contract and verify the results

What You Will Need


Setting up the Development Environment

We will need a new folder locally where our project for this tutorial will live. We will name ours UpgradeableContracts, but you can call it anything you like. Run these commands in your terminal to create the folder and navigate into it:

mkdir UpgradeableContracts
cd UpgradeableContracts

Great! Now that we have a blank canvas to work on, let us get down to painting it. Enter the following command to create your Hardhat project:

npx hardhat --init

You will be prompted with a few questions:

  • If prompted with what version of Hardhat you want to use, select the Hardhat 3 option which is selected by default.
  • When prompted What type of project would you like to initialize?, select the mocha-ethers option.
  • You will be asked to install dependencies. Type Y and hit enter.
  • Following the instructions on screen, your workspace should look something like this:

Hardhat Configuration Setup

Congratulations! You just successfully installed and initialized Hardhat!

Once the installation is complete, you should now have everything you need to develop, test and deploy smart contracts on the blockchain. Since we’ll be working with upgradeable smart contracts, we will need to install a few more dependencies. Execute the following lines in your terminal:

npm i -D @nomicfoundation/hardhat-ethers @nomicfoundation/hardhat-verify
npm i @openzeppelin/contracts dotenv

@openzeppelin/contracts is the package that allows us to deploy our smart contracts in a way that allows them to be upgradeable. @nomicfoundation/hardhat-verify is a hardhat plugin that allows us to verify our contracts in the blockchain. This allows anyone to interact with your deployed contracts and provides transparency. Using the hardhat plugin is the most convenient way to verify our contracts. @nomicfoundation/hardhat-ethers is a plugin that brings the ethers.js library into Hardhat, allowing us to interact with the blockchain.

The dotenv package will be used for environment variable management. Create an .env file in the root directory of your project. This file will be used to store sensitive data such as your private key and API keys. Make sure to add .env to your .gitignore file so that you do not accidentally push it to a public repository if you are using Git. Here is how your .env file should look like:

RPC_URL="YOUR_QUICKNODE_RPC_URL"
PRIVATE_KEY="YOUR_METAMASK_PRIVATE_KEY"
ETHERSCAN_API_KEY="YOUR_ETHERSCAN_API_KEY"

Kudos if you were able to follow the tutorial up to here. You just set up a smart contract development environment using Hardhat and installed additional dependencies that will allow us to deploy and verify upgradeable smart contracts.

Accessing a Polygon Amoy Testnet Node

We'll need to deploy our contracts on the Polygon Amoy Testnet. We can simply sign up for a free QuickNode trial here and create a Polygon Amoy Testnet endpoint.

QuickNode Endpoints page

Copy the HTTPS URL and paste it into a RPC_URL variable in your .env file. If you do not have an .env file, create one in the root directory of your project. This file will be used to store sensitive data such as your private key and API keys.

Next, go to your profile on Etherscan and navigate to the API KEYS tab. If you do not have an account, create one here. Here you will create an API key that will help you verify your smart contracts on the blockchain. Copy the API key and paste it into the ETHERSCAN_API_KEY variable in your .env file.

Note: Etherscan has upgraded their API to v2. When you create an API key on Etherscan, it can also be used for multiple chains that Etherscan supports, including Polygon. So you do not need to create a separate API key for PolygonScan.

Lastly, go into your MetaMask and copy the private key of one of your accounts. To learn how to access your private key, check out this short guide. Paste this private key into the PRIVATE_KEY variable in your .env file.

You will also need to have a few Amoy Testnet MATIC in your account to deploy your contracts. You can get some at this faucet.

Finally, open your hardhat.config.ts file, and replace the entire code with this:

import type { HardhatUserConfig } from "hardhat/config";

import hardhatToolboxMochaEthersPlugin from "@nomicfoundation/hardhat-toolbox-mocha-ethers";
import hardhatVerify from "@nomicfoundation/hardhat-verify";
import 'dotenv/config';

const config: HardhatUserConfig = {
plugins: [
hardhatToolboxMochaEthersPlugin,
hardhatVerify,
],
paths: {
sources: "./contracts",
tests: "./test",
cache: "./cache",
artifacts: "./artifacts",
},
solidity: {
profiles: {
default: {
version: "0.8.22",
},
},
},
verify: {
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
}
},
networks: {
amoy: {
type: "http",
url: process.env.RPC_URL!,
accounts: [process.env.PRIVATE_KEY!],
},
},
};

export default config;

The first few lines we've used to import several libraries we'll need.

To confirm everything runs correctly, save all your files and compile the contracts once more by running the command:

npx hardhat compile

If you followed all the steps correctly, Hardhat will compile your contracts and give you a confirmation message. We’re now ready to deploy our contracts.

Creating our Smart Contracts

In this section, we will create four smart contracts. The first two contracts will be our implementation contracts, which we will name V1.sol and V2.sol. The third contract will be the TransparentUpgradeableProxy.sol contract, which is the proxy contract that will point to our implementation contracts. The fourth contract will be the ProxyAdmin.sol contract, which has ownership of the proxy and will be used to manage the proxy contract.

Go into the contracts folder, and delete the pre-existing Counter.sol file. That is a default smart contract template provided by Hardhat and we don’t need it. Create four new files in the contracts folder, and name them V1.sol, V2.sol, TransparentUpgradeableProxy.sol and ProxyAdmin.sol. Paste the following code into each of the files respectively.

cd contracts
touch V1.sol V2.sol TransparentUpgradeableProxy.sol ProxyAdmin.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract V1 {
uint public number;

function initialValue(uint _num) external {
number=_num;
}

function increase() external {
number += 1;
}
}

Note: The TransparentUpgradeableProxy.sol and ProxyAdmin.sol contracts are directly taken from OpenZeppelin's GitHub repository. You can find these contracts here.

The V1 contract is pretty simple. It has one state variable of type unsigned integer and two functions. The function initialValue() simply sets the initial value of the variable, while the function increase() increments its value by 1.

Initially, our proxy points to the V1 implementation. We will be upgrading it to the V2 implementation. In the V2 contract, we merely add a function decrease(), which will decrease the value of the variable by 1.

Save the files that you have been working with and navigate back to the terminal. Confirm that you are in the project directory (e.g, UpgradeableContracts) and then run this command in your terminal:

npx hardhat compile

If you did everything correctly, the terminal should tell you that it has compiled two solidity files successfully. We are now ready to configure our deployment tools. The next section will teach you the best practices when it comes to deploying your contracts.

Hardhat Ignition Modules

We will be utilizing Hardhat Ignition modules to prepare to deploy our contracts. Modules make it easy to configure and group smart contract instances that we wish to deploy. We will be creating a module called ProxyModule.

The ProxyModule will describe the deployments of the V1 and V2 implementation contracts, the TransparentUpgradeableProxy proxy contract, and the ProxyAdmin contract. We will also set an initialValue of 10 for the V1 contract in this module.

In the ignition/modules folder, delete the pre-existing Counter.ts file. Create a file named proxyModule.ts and paste the following code:

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

const proxyModule = buildModule("ProxyModule", (m) => {
const proxyAdminOwner = m.getAccount(0);

const v1 = m.contract("V1");
const v2 = m.contract("V2");

const proxy = m.contract("TransparentUpgradeableProxy", [
v1,
proxyAdminOwner,
"0x",
]);

const proxyAdminAddress = m.readEventArgument(
proxy,
"AdminChanged",
"newAdmin"
);

const proxyAdmin = m.contractAt("ProxyAdmin", proxyAdminAddress);

return { implementation: v1, proxyAdmin, proxy };
});

const v1Module = buildModule("V1Module", (m) => {
const { implementation, proxy, proxyAdmin } = m.useModule(proxyModule);

const v1 = m.contractAt("V1", proxy);
m.call(v1, "initialValue", [10]);

return { implementation, v1, proxy, proxyAdmin };
});

export default v1Module;

Now that we have created our modules, we will now move on to deploying our contracts!

Deploying the Contracts

We are now ready to deploy our upgradeable smart contract! Execute the following command to begin the deployment process:

npx hardhat ignition deploy ignition/modules/ProxyModule.ts --network amoy --verify

The --network flag specifies the network we wish to deploy our contracts to. In this case, we are deploying to the Polygon Amoy testnet.

The --verify flag will automatically verify our contracts on Polygonscan after they are deployed.

Note: If you deployed already or something went wrong, add the --reset flag to the command above to force a redeployment.

Terminal output from deploying our module

Logs for Simplified Debugging

You can now access Logs for your RPC endpoints, helping you troubleshoot issues more effectively. If you encounter an issue with your RPC calls, simply check the logs in your QuickNode dashboard to identify and resolve problems quickly. Learn more about log history limits on our pricing page.

Output Breakdown

If you followed the steps correctly, you should see something similar to the image above in your terminal. The terminal output shows that the V1, V2, TransparentUpgradeableProxy, and ProxyAdmin contracts have been deployed successfully with their respective addresses.

Contract NameDescription
ProxyModule#V1The first version of our implementation contract with an initial value and an increase. Recall we set the initial value to 10 in the module
ProxyModule#V2The second version of our implementation contract with an added decrease function.
ProxyModule#TransparentUpgradeableProxyThe proxy contract that points to our current implementation contract. Currently, it points to V1.
ProxyModule#ProxyAdminThe contract that can upgrade the proxy. Only the owner of the ProxyAdmin can upgrade the TransparentUpgradeableProxy implementation.

IMPORTANT: Make sure to keep note of the ProxyModule#TransparentUpgradeableProxy, ProxyModule#V2, ProxyModule#ProxyAdmin addresses under the Deployed Addresses section in the terminal as we will need them later.

You may be wondering what exactly is happening behind the scenes. Let’s pause and find out.

Peeking Under the Hood

We will take a better look of the contracts we deployed. First, go to MetaMask and copy the public address of the account that you used to deploy the smart contracts. Open the Amoy Testnet explorer, and search for your account address.

You will see that your account has made 4 transactions:

  • The first two transactions are the deployment of the V1 and V2 implementation contracts.
  • The third transaction is the deployment of the TransparentUpgradeableProxy and ProxyAdmin contracts
  • The fourth transaction is setting the initial value of 10 in our proxy contract.

PolygonScan Account Transactions Page

To see each individual contract, you can click the Contract Creation link under the To field on the Transactions tab.

Let's take a look at the TransparentUpgradeableProxy contract. Click on the To link in the third transaction (highlighted above). This will take you to the contract page. Then head over to the Contract tab with a check mark. You should see something like this:

PolygonScan Contract Proxy Tab

On this page, we can see the source code of our TransparentUpgradeableProxy contract meaning it has been verified successfully. But how do we know if the implementation of this proxy contract is indeed our V1 contract? Click the More Options dropdown right above the source code (as shown above). Click Is this a Proxy?

Polygonscan Proxy Verification

You will then be asked to verify the contract as a proxy. Click Verify. A popup will appear and will display the implementation address. You will notice that this is the same address as our V1 contract! Click Save then go back to our TransparentUpgradeableProxy contract page. You should now see two new tabs! Read as Proxy and Write as Proxy.

Click on the number function under the Read as Proxy tab. You should see the value 10, which is the initial value we set earlier. If you click on Write as Proxy, you will see the V1 contract's functions. This confirms that our proxy contract indeed points to our V1 implementation contract.

Proxy Contract Breakdown

The ProxyModule described multiple smart contracts to be deployed, namely the TransparentUpgradeableProxy and ProxyAdmin contracts. TransparentUpgradeableProxy is the main contract here. This contract holds all the state variable changes for our implementation contract. This means that the implementation contract does not maintain its own state and actually relies on the proxy contract for storage.

As a demonstration, search up the V1 contract address you noted down earlier on the Amoy block explorer. If you head over to the Contract tab and click on Read Contract, you will see that the value of the state variable number is 0! Recall that we saw the value of number as 10 when we read it from the proxy contract. This shows that the implementation contract does not maintain its own state but solely serves as a logic contract.

In this scenario, the proxy contract (TransparentUpgradeableProxy) is the wrapper for our implementation contract (V1), and if and when we need to upgrade our smart contract (via ProxyAdmin), we simply deploy another contract and have our proxy contract point to that contract, thus upgrading its state and future functionality. How cool is that!

In the end, we did not actually alter the code in any of our smart contracts, yet from the user’s perspective, the main contract has been upgraded. This flow chart will give you a better understanding:

Upgradeable Smart Contracts Flowchart

Upgrading Contract V1 to V2

Now that we have a solid understanding of what's happening on the backend, let us return to our code and upgrade our contract! Under the scripts folder, create a new file named upgrade.ts. Inside, paste the following code:

import hre from "hardhat";

async function main() {
const { ethers } = await hre.network.connect({
network: "amoy",
});

const [currentAccount] = await ethers.getSigners();
console.log("Current account:", currentAccount.address);

const proxy = "<INSERT_YOUR_PROXY_ADDRESS_HERE>";
const proxyAdmin = "<INSERT_YOUR_PROXY_ADMIN_ADDRESS_HERE>";
const v2Implementation = "<INSERT_YOUR_V2_IMPLEMENTATION_ADDRESS_HERE>";

const proxyAdminContract = await ethers.getContractAt("ProxyAdmin", proxyAdmin);
const v2 = await ethers.getContractAt("V2", v2Implementation);

const encodedFunctionCall = v2.interface.encodeFunctionData("decrease");

console.log("Upgrading to V2...");
const upgradeTx = await proxyAdminContract.connect(currentAccount).upgradeAndCall(proxy, v2Implementation, encodedFunctionCall);
await upgradeTx.wait();
console.log("Upgraded to V2");

const v2Proxy = await ethers.getContractAt("V2", proxy);
const currentValue = await v2Proxy.number();
console.log("Current value after upgrade and call:", currentValue.toString());
}

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

IMPORTANT: Before we upgrade our contract, remember to paste your TransparentUpgradeableProxy, ProxyAdmin, and V2 contract addresses in the respective variables in the code above.

Now, let us run this script in the terminal:

npx hardhat run scripts/upgrade.ts --network amoy

Upgrade Breakdown

What basically happened here is that we called the upgradeAndCall function inside the ProxyAdmin contract to first upgrade the implementation contract then call the decrease function. If you check the terminal output, you will see that the current value is now 9, which means that the decrease function was successfully called after the upgrade. To further confirm that the decrease function call succeeded, if you return to the Read as Proxy tab of the TransparentUpgradeableProxy contract on the Amoy block explorer, you will see that the value of number is now 9!

> npx hardhat run scripts/upgrade.ts --network amoy

Compiling your Solidity contracts...
Compiled 4 Solidity files with solc 0.8.22 (evm target: shanghai)

Current account: 0x779fE50d4A9d0Eb760dD0d315b91d6A5CF265FCC
Upgrading to V2...
Upgraded to V2
Current value after upgrade and call: 9

Do note that only the account that deployed the proxy contracts can call the upgrade function, and that is for obvious reasons. The TransparentUpgradeableProxy contract now points to the address of the newly deployed V2 contract. Check out the flow chart below:

Upgradeable Smart Contracts Flowchart

Please note that the address of the user who calls a particular function (msg.sender) is critical. The address determines the entire logic flow.

If the msg.sender is any other user besides the admin, then the proxy contract will simply delegate the call to the implementation contract, and the relevant function will execute. Thus, the proxy contract calls the appropriate function from the implementation contract on behalf of msg.sender, the end-user. As explained before, the state of the implementation contract is meaningless, as it does not change. What does change is the state of the proxy contract, which is determined on the basis of what is returned from the implementation contract when the required function executes.

This means that if the caller is not an admin, the proxy contract will not even consider executing any sort of upgrade function. If the caller is not an admin, the call is forwarded or delegated to the implementation contract without any further delay. This is called a delegate call and is an important concept to understand. If the caller is however the admin, in this case, our ProxyAdmin contract, the call is not automatically delegated, and any of the functions of the proxy contract can be executed, including the upgrade function.

Verifying the Upgrade

Now, go back to the Amoy block explorer and search for your TransparentUpgradeableProxy contract address. Go to the Contract tab and click on Write as Proxy. You should now see the new V2 implementation contract address followed by the previous V1 implementation address. You will also see the decrease function available for use. This tells us that the proxy contract now points to the new implementation contract! Here's a before and after comparison:

That’s it. You just deployed an upgradeable smart contract and then upgraded it to include a new function. Now push the code to Github and show it off! One last caveat, remember how we used a .env file to store our sensitive data? The purpose of the file was to prevent our sensitive data from being published publicly, thus compromising our assets on the blockchain. After verifying that you have the .env file name listed in your .gitignore, you can then push your code to GitHub without worries since you have no private data in your hardhat.config.ts file.

Conclusion

Give yourselves a pat on the back. You have earned it. This was a fairly advanced tutorial, and if you followed it thoroughly, you now understand how to deploy a basic upgradeable contract using the OpenZeppelin library.

Subscribe to our newsletter for more articles and guides on Polygon. If you have any feedback, feel free to reach out to us via X. You can always chat with us on our Discord community server, featuring some of the coolest developers you’ll ever meet 😊

We ❤️ Feedback!

If you have any feedback or questions about this documentation, let us know. We'd love to hear from you!

Share this guide