Create your own
Lesson illustration

Deploying UUPS Proxies for Upgradeable Contracts

Hello! Welcome back to your course on Ethereum development.

Introduction

In our last lesson, you mastered how to create reusable, network-agnostic deployment scripts—a cornerstone of professional smart contract engineering. You learned to manage network configurations and secrets, allowing you to deploy the same contract to any EVM chain with a single command.

However, what happens when you need to fix a bug or add a new feature to a contract that's already live and holding state? By default, smart contracts are immutable. This is a feature for security and predictability, but a challenge for long-term maintenance.

This lesson tackles that challenge head-on, addressing the learning outcome: "Integrate a UUPS proxy pattern into a deployment script to manage contract upgrades."

We will explore the UUPS (Universal Upgradeable Proxy Standard) pattern, a modern and gas-efficient method for making your contracts upgradeable. We'll leverage the skills from the previous lesson, adapting our deployment scripts to handle the initial deployment and subsequent upgrades of a proxy contract. Your background in managing complex software lifecycles will be valuable here as we apply similar principles to the blockchain.

By the end of this lesson, you will be able to:

  1. Explain the UUPS proxy pattern and its advantages over the Transparent Proxy Pattern.
  2. Modify a smart contract to be compatible with the UUPS pattern using OpenZeppelin's contracts.
  3. Use the OpenZeppelin Upgrades plugin for Hardhat to script the deployment of a UUPS proxy.
  4. Write and execute a script to upgrade a live contract to a new version while preserving its state and address.

1. The "Why" and "How" of Upgradeable Contracts

Before we write any code, it's crucial to understand the mechanics of proxy patterns. A proxy pattern splits a contract's state and logic into two separate contracts:

  • The Proxy Contract: This is the contract users interact with. It holds all the state (the data) and has a permanent address.
  • The Implementation Contract: This contract contains the business logic. It is stateless and can be replaced.

When a user calls a function on the proxy, the proxy uses a special EVM opcode called delegatecall to forward the call to the current implementation contract. delegatecall executes the implementation's code in the context of the proxy's storage. This means the logic from the implementation contract modifies the state of the proxy contract.

An upgrade is simply deploying a new implementation contract (V2) and telling the proxy to use this new address for future calls.

The following video from OpenZeppelin provides an excellent overview of this pattern and introduces the two main variants: Transparent and UUPS.

Deploying More Efficient Upgradeable Contracts

This first segment explains the core proxy pattern and introduces the fundamental difference between the Transparent Proxy Pattern (TPP) and the UUPS pattern, which boils down to where the upgrade logic itself is stored.

Watch from 00:03:20 to 00:06:34. Focus on understanding the roles of the proxy and implementation contracts and the concept of delegatecall.

2. UUPS vs. Transparent Proxy: The Trade-offs

As the video mentioned, the key difference is where the upgradeTo() function lives.

  • Transparent Proxy Pattern (TPP): The upgrade logic is in the proxy itself. This makes the proxy contract larger and more expensive to deploy. It also adds a small gas overhead to every single call because the proxy must first check if the caller is an admin trying to upgrade or a regular user.
  • UUPS (Universal Upgradeable Proxy Standard): The upgrade logic is in the implementation contract. This results in a much simpler, smaller, and cheaper proxy. There is no gas overhead on regular user calls.

For a deeper dive into the trade-offs, the next segment of the video is invaluable. It discusses gas costs, security, and flexibility.

Deploying More Efficient Upgradeable Contracts

This segment provides a detailed comparison of the two patterns. It covers the gas implications, the risk of function selector clashes in TPP, and the flexibility of UUPS.

Watch from 00:06:34 to 00:18:43. Pay attention to the discussion on storage access costs and why UUPS has a lower runtime overhead for your users.

Key Comparison:

FeatureTransparent Proxy Pattern (TPP)UUPS Proxy Pattern
Upgrade Logic LocationIn the Proxy contractIn the Implementation contract
Deployment Gas CostHigher (Proxy is complex + requires a ProxyAdmin contract)Lower (Proxy is minimal)
Runtime Gas CostHigher (Admin check on every call)Lower (Direct delegatecall)
FlexibilityUpgrade mechanism is fixedUpgrade mechanism can be customized and upgraded
Primary RiskFunction selector clashesForgetting to include upgrade logic in a new version

For most new projects, UUPS is the recommended pattern due to its efficiency and flexibility. The OpenZeppelin Upgrades plugin mitigates the risk of forgetting the upgrade logic.

3. Preparing a UUPS-Compatible Contract

To make a contract upgradeable with the UUPS pattern, you must follow a few rules. The OpenZeppelin Upgrades plugin will enforce these for you.

  1. No Constructor: You cannot use a constructor. Instead, you must use an initializer function that is called only once to set up the initial state.
  2. Inherit UUPSUpgradeable: You must inherit from OpenZeppelin's UUPSUpgradeable contract. This provides the necessary upgradeTo function.
  3. Implement _authorizeUpgrade: You must override the internal function _authorizeUpgrade to define who is allowed to perform upgrades. This is typically restricted to an owner.

The following resource provides a clear, complete example of a UUPS-compatible contract.

Deploying Upgradeable Contracts using UUPS with Hardhat

This guide from the Conflux documentation shows a simple Counter contract prepared for UUPS deployment. It's a great reference for the required imports and structure.

Review the section 'Writing Smart Contracts'. Examine both the Counter.sol (V1) and CounterV2.sol contracts. Note the use of UUPSUpgradeable, OwnableUpgradeable, the initialize function, and the _authorizeUpgrade function with the onlyOwner modifier.

Here's the core structure of the V1 contract from that resource:

// contracts/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract Counter is UUPSUpgradeable, OwnableUpgradeable {
    uint256 private count;

    // Replaces the constructor
    function initialize() public initializer {
        __Ownable_init(msg.sender); // Initializes the owner
        __UUPSUpgradeable_init();   // Initializes UUPS
    }

    function increment() public {
        count += 1;
    }

    function getCount() public view returns (uint256) {
        return count;
    }

    // Required for UUPS. Defines who can upgrade.
    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}

4. Scripting the Initial Deployment

Now, let's integrate this into a deployment script. This builds directly on last lesson's topic. We'll use the @openzeppelin/hardhat-upgrades plugin, which provides a powerful upgrades.deployProxy function.

This function automates the entire process:

  1. Validates that your contract is upgrade-safe.
  2. Deploys your implementation contract (e.g., Counter.sol).
  3. Deploys a minimal UUPS proxy contract.
  4. Links the proxy to your implementation.
  5. Calls your initialize function.

The script is remarkably simple.

Deploying Upgradeable Contracts using UUPS with Hardhat

This section of the Conflux guide provides the deployment script. It shows how to use upgrades.deployProxy with the crucial kind: 'uups' option.

Read the 'Deployment Script' section. This script is the template for deploying any UUPS contract.

Here is the script for easy reference:

// scripts/deploy.js
const { ethers, upgrades } = require("hardhat");

async function main() {
  const Counter = await ethers.getContractFactory("Counter");
  console.log("Deploying Counter (UUPS)...");

  const counter = await upgrades.deployProxy(Counter, {
    kind: "uups",
    initializer: "initialize",
  });

  await counter.waitForDeployment();
  const proxyAddress = await counter.getAddress();
  console.log("Counter (Proxy) deployed to:", proxyAddress);
}

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

You would run this script just as you learned in the previous lesson:
npx hardhat run scripts/deploy.js --network <your-network-name>

5. Scripting the Upgrade

After your contract is live, you'll eventually want to upgrade it. Let's say you've created CounterV2.sol, which adds a reset() function.

The process involves another helper function: upgrades.upgradeProxy. This function handles deploying the new V2 implementation and calling the upgradeTo function on the existing proxy to switch over.

A critical part of this process is ensuring storage compatibility. You can add new state variables to your V2 contract, but you must not change the order, type, or name of existing variables from V1. The OpenZeppelin plugin automatically checks for this and will fail the upgrade if you make an incompatible change, preventing catastrophic storage corruption.

This final video segment demonstrates both the upgrade process and the safety checks.

Deploying More Efficient Upgradeable Contracts

This part of the OpenZeppelin video shows how to write an upgrade test/script using upgradeProxy. Crucially, it also demonstrates how the plugin detects and prevents a dangerous storage layout incompatibility.

Watch from 00:28:58 to 00:37:17. Focus on the upgradeProxy function call and the error that occurs when a state variable is renamed instead of appended.

The script to perform the upgrade is just as straightforward as the deployment script.

Deploying Upgradeable Contracts using UUPS with Hardhat

Finally, this section of the guide provides the upgrade.js script. It requires the address of the already-deployed proxy.

Read the 'Upgrade Script' section. Note how it takes the proxy address and the V2 contract factory as arguments to upgrades.upgradeProxy.

Here is the upgrade script:

// scripts/upgrade.js
const { ethers, upgrades } = require("hardhat");

// IMPORTANT: Replace with your proxy address from the deployment step
const PROXY_ADDRESS = "YOUR_PROXY_ADDRESS_HERE";

async function main() {
  const CounterV2 = await ethers.getContractFactory("CounterV2");
  console.log("Upgrading Counter to V2...");

  const upgraded = await upgrades.upgradeProxy(PROXY_ADDRESS, CounterV2);
  await upgraded.waitForDeployment();

  console.log("Counter upgraded successfully");
}

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

To execute the upgrade, you would run:
npx hardhat run scripts/upgrade.js --network <your-network-name>

After the script completes, your original proxy address will still be the entry point, but it will now delegate calls to the new CounterV2 logic, and you'll be able to call the new reset() function. The count value from before the upgrade will be preserved.

Conclusion

You have now integrated a sophisticated and essential pattern into your deployment workflow. By using UUPS proxies with the OpenZeppelin Upgrades plugin, you can manage the entire lifecycle of a smart contract, from initial deployment to subsequent feature additions and bug fixes, all through automated, reusable scripts.

Key Takeaways:

  • The UUPS pattern places upgrade logic in the implementation contract, making the proxy smaller and cheaper and reducing gas costs for users.
  • To make a contract upgradeable, you must use an initializer function instead of a constructor, inherit from UUPSUpgradeable, and implement _authorizeUpgrade.
  • The @openzeppelin/hardhat-upgrades plugin simplifies the process with two main functions:
    • upgrades.deployProxy(ContractFactory, { kind: 'uups', ... }) for the initial deployment.
    • upgrades.upgradeProxy(proxyAddress, NewContractFactory) for performing an upgrade.
  • The plugin provides crucial safety checks, most importantly for storage layout compatibility, to prevent data corruption during an upgrade.

Next Lesson Preview:

Deploying and upgrading contracts is a huge step. But once they are live on the network, how do you know what they're doing? How can you react to critical events or unexpected behavior? In our next lesson, we will address this by learning how to "Set up a monitoring service (e.g., Tenderly, OpenZeppelin Defender) to observe a deployed contract," moving from deployment to operations.

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

Sign up