Skip to main content
Create your own

Deploying Upgradeable Contracts with Hardhat and OpenZeppelin

In the last lesson, we explored the theory behind the transparent proxy pattern, the standard solution for making smart contracts upgradeable. You learned how separating a contract's state and logic allows for bug fixes and new features without requiring a difficult and costly migration.

Today, we transition from theory to practice. This lesson will guide you through the hands-on process of deploying an upgradeable contract using the industry-standard toolchain: Hardhat combined with OpenZeppelin's Upgrades Plugins. You will learn how to prepare your contracts for upgradeability and write scripts that automate the complex deployment of the proxy, implementation, and admin contracts.

1. Setting Up the Tools

To manage upgradeable deployments, we'll use a specialized Hardhat plugin provided by OpenZeppelin. This plugin adds new functions to the Hardhat environment, such as deployProxy and upgradeProxy, which handle the complexities of the transparent proxy pattern for us.

First, you need to install the plugin and its required dependencies into your project. Open your terminal in your project's root directory and run the following command:

npm install --save-dev @openzeppelin/hardhat-upgrades @nomicfoundation/hardhat-ethers ethers

Next, you need to register the plugin in your Hardhat configuration file.

Using with Hardhat

This official documentation from OpenZeppelin shows the necessary installation and configuration steps.

Please locate the hardhat.config.js file in your project. In the documentation, find the code snippet under the text "And register the plugin" and add the require statement to the top of your configuration file.

With this setup complete, your Hardhat environment is now equipped to manage upgradeable contracts.

2. Preparing a Contract for Upgradeability

As we discussed previously, proxy contracts rely on delegatecall to execute logic from an implementation contract. This has a significant consequence: the implementation contract cannot have a constructor. A constructor's code is executed only once when the contract is created, but the implementation contract's code is meant to be run in the context of the proxy's storage.

To work around this, upgradeable contracts use a special initialize function instead of a constructor. The OpenZeppelin Upgrades Plugin will automatically call this function once when the proxy is first deployed.

The following video from OpenZeppelin demonstrates how to convert a standard contract into an upgradeable one. The example uses an ERC-20 token, but the principles apply to any contract.

Deploying More Efficient Upgradeable Contracts

This video shows the process of adapting a contract to be compatible with the upgrades plugin.

Watch the segment from "our own Mars contract now has a constructor". Pay close attention to these three key changes: The constructor is replaced with a public function named initialize. The initializer modifier is added to this function. This is crucial as it prevents the function from ever being called a second time. The parent contract's initializer (__ERC20_init) is called inside the initialize function, since parent constructors are no longer called automatically.

For our exercise, let's use two simple contracts, Box.sol (version 1) and BoxV2.sol (version 2), which are excellent for focusing purely on the upgrade mechanism.

contracts/Box.sol

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

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract Box is Initializable {
    uint256 private _value;

    // The 'initializer' modifier ensures this function can only be called once.
    function initialize(uint256 value) public initializer {
        _value = value;
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

contracts/BoxV2.sol

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

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract BoxV2 is Initializable {
    uint256 private _value;

    // Notice there is no initialize function here. It has already been run in V1.
    // The state is carried over from the V1 deployment.

    function retrieve() public view returns (uint256) {
        return _value;
    }

    // New function added in V2
    function increment() public {
        _value = _value + 1;
    }
}

Before you continue, you will need to install the OpenZeppelin upgradeable contracts library:

npm install --save-dev @openzeppelin/contracts-upgradeable

Create these two files in your contracts directory. Notice that Box.sol uses initialize instead of a constructor. BoxV2.sol adds a new increment function and omits the initialize function because the state (the value of _value) is already initialized and stored in the proxy from the V1 deployment.

3. Deploying the Proxy

Now, let's write a Hardhat script to deploy our Box.sol contract as an upgradeable instance. The Upgrades Plugin makes this incredibly straightforward with the upgrades.deployProxy function.

Create a new file scripts/deploy.js:

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

async function main() {
  const Box = await ethers.getContractFactory("Box");
  console.log("Deploying Box...");
  
  // deployProxy deploys the implementation, proxy, and proxy admin
  // It also calls the 'initialize' function with the provided arguments
  const box = await upgrades.deployProxy(Box, [42], {
    initializer: "initialize",
  });
  await box.waitForDeployment();
  
  const boxAddress = await box.getAddress();
  console.log("Box (proxy) deployed to:", boxAddress);
}

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

This script might look simple, but deployProxy is doing a lot of work:

  1. It compiles your Box.sol contract.
  2. It deploys the Box contract as the first implementation contract.
  3. It deploys a ProxyAdmin contract, which will be the owner of the proxy and handle upgrades.
  4. It deploys the TransparentUpgradeableProxy contract itself.
  5. It links the proxy to the implementation address and sets the ProxyAdmin.
  6. Finally, it calls the initialize function on the proxy with the argument 42.

Run this script on your local Hardhat network:
npx hardhat run scripts/deploy.js --network localhost

The output will be the address of the proxy contract. This is the single, stable address that your users will always interact with.

4. Scripting the Upgrade

The real power of this pattern becomes clear when you need to upgrade. Let's write a script to upgrade our deployed Box to BoxV2. The process is just as streamlined using the upgrades.upgradeProxy function.

A script using Hardhat and OpenZeppelin's Upgrades Plugins. The `upgradeProxy` (or in this case, `prepareUpgrade`) function is used to point the proxy to a new implementation contract, `BoxV2`.

Create a new file scripts/upgrade.js:

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

// The address of the proxy we deployed in the previous step
const PROXY_ADDRESS = "YOUR_DEPLOYED_PROXY_ADDRESS"; // <-- Replace this

async function main() {
  const BoxV2 = await ethers.getContractFactory("BoxV2");
  console.log("Upgrading Box...");
  
  // upgradeProxy upgrades the implementation to BoxV2
  // The state and address of the proxy are preserved
  const boxV2 = await upgrades.upgradeProxy(PROXY_ADDRESS, BoxV2);
  await boxV2.waitForDeployment();
  
  console.log("Box upgraded to V2");
}

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

Before running this, replace "YOUR_DEPLOYED_PROXY_ADDRESS" with the actual proxy address from the previous step's output.

The upgradeProxy function performs the following actions:

  1. Deploys the new implementation contract, BoxV2.
  2. Calls the upgrade function on the ProxyAdmin contract, providing it with the proxy's address and the new BoxV2 implementation address.
  3. The ProxyAdmin then instructs the proxy to update its implementation pointer.

Crucially, the proxy's address and its storage (the value 42) remain unchanged. All future calls to the proxy will now be delegated to the new logic in BoxV2.

5. A Note on UUPS: A More Efficient Pattern

The transparent proxy pattern we've used is robust and secure, but it has a small gas overhead on every single function call because the proxy needs to read from storage to check if the caller is the admin.

An alternative pattern, called UUPS (Universal Upgradeable Proxy Standard), reduces this overhead.

Deploying More Efficient Upgradeable Contracts

This segment of the OpenZeppelin talk provides a clear and concise comparison of the Transparent and UUPS proxy patterns.

To understand the trade-offs, watch from "when every time that a user calls into the proxy". This explains the gas overhead of transparent proxies. Then, see how UUPS solves this from "the other choice is we put the upgrade function".

In summary:

  • Transparent Proxy: Upgrade logic is in the proxy. Pro: safer, as you can't accidentally remove the upgrade mechanism. Con: higher gas cost for every user transaction.
  • UUPS Proxy: Upgrade logic is in the implementation contract. Pro: cheaper for users. Con: developers must ensure every new version includes the upgrade logic, or upgradeability is lost forever.

The OpenZeppelin Upgrades Plugin supports UUPS as well. You can enable it by passing { kind: 'uups' } to the deployProxy function. For many applications, especially those with high transaction volume like token transfers, UUPS is becoming the preferred choice.

Conclusion

In this lesson, you have bridged the gap between theory and practice by deploying and scripting an upgrade for a smart contract. You now have a practical workflow for managing the lifecycle of your contracts, a critical skill for professional blockchain development.

Key Takeaways:

  • The @openzeppelin/hardhat-upgrades plugin automates the deployment of upgradeable contracts.
  • Upgradeable contracts must use an initialize function with the initializer modifier instead of a constructor.
  • upgrades.deployProxy handles the initial setup of the implementation, proxy, and admin contracts.
  • upgrades.upgradeProxy seamlessly points an existing proxy to a new implementation contract, preserving state and address.
  • UUPS is a more gas-efficient proxy pattern that moves upgrade logic into the implementation contract, offering a trade-off between runtime cost and development risk.

In the next lesson, we will take these scripts and execute a full deployment and upgrade cycle on a public testnet. You will see how to interact with the upgraded contract on a block explorer and verify that its state was indeed preserved across the upgrade.

Can't find a good explanation? Sign up and we'll make it for you

Sign up