Create your own
Lesson illustration

Implementing UUPS Upgradeable Contracts

Hello! Welcome to your eighth and final lesson in the "Advanced Solidity & Secure Development" module.

Introduction

In our last lesson, we established a strong theoretical foundation for smart contract upgradeability. We compared the Transparent and UUPS proxy patterns, concluding that UUPS is the modern, gas-efficient, and flexible standard for new projects, despite its unique risks.

Today, we transition from theory to practice. You will use your development skills to implement, deploy, and upgrade a smart contract using the UUPS pattern. We will leverage the industry-standard OpenZeppelin contracts and the Hardhat Upgrades plugin, which streamline this complex process and enforce critical safety checks.

Learning Outcome: By the end of this lesson, you will be able to implement an upgradeable contract using the UUPS proxy pattern.

We will build an upgradeable ERC-20 token, which serves as a perfect practical example and provides a smooth transition into our next module on DeFi primitives.

1. The UUPS Architecture with OpenZeppelin

Let's quickly recap the UUPS architecture before we start coding.

  • Proxy Contract (ERC1967Proxy): A minimal, generic contract that holds the state and the address of the current implementation. Users interact with this contract's address.
  • Implementation Contract (Your Logic): This contract contains the business logic. It is stateless itself but operates on the proxy's storage via delegatecall.
  • Upgrade Logic: The logic to perform an upgrade (i.e., change the implementation address in the proxy) resides within the implementation contract.

This diagram shows the basic flow: a user calls the proxy, which delegates the call to the implementation contract.

This diagram illustrates the UUPS interaction flow. The key feature is that the upgrade logic, `upgradeTo()`, is part of the implementation contract (V2 in this case), not the proxy.

To make this pattern safe and robust, OpenZeppelin's UUPSUpgradeable contract provides two crucial features:

  1. An upgradeTo() function that handles the low-level storage update in the proxy.
  2. A safety check mechanism that prevents you from upgrading to a new implementation that is not itself upgradeable, thus avoiding "bricking" your contract. This is done via a function called proxiableUUID().
This diagram shows how UUPS ensures safe upgrades. Before upgrading, the current implementation checks if the new implementation is UUPS-compliant by calling its `proxiableUUID()` function. If the check fails, the upgrade is aborted.

2. Implementing a UUPS Contract: A Practical Walkthrough

We will now walk through the process of creating and deploying a UUPS-compliant contract. The OpenZeppelin team has produced an excellent video that demonstrates this entire workflow using Hardhat. We will use it as our primary guide.

This video covers the theory we've discussed and then moves into a live-coding session that perfectly aligns with our learning outcome.

Deploying More Efficient Upgradeable Contracts

This video, 'Deploying More Efficient Upgradeable Contracts' by OpenZeppelin, is our main resource. It explains the UUPS pattern and then demonstrates its implementation with the OpenZeppelin Hardhat Upgrades plugin.

Please watch from 00:11:14 to 00:28:21. Part 1 (11:14 - 18:58): Focus on the explanation of the UUPS pattern's mechanics, its advantages in gas costs and flexibility, and the primary risk of accidentally removing the upgrade function. Part 2 (18:58 - 28:21): This is the core practical section. Pay close attention to the code modifications required to make a standard contract upgradeable. Specifically, note the changes from a constructor to an initialize function and the addition of UUPSUpgradeable and _authorizeUpgrade.

Summary of Key Implementation Steps

Based on the video, here is a checklist for converting a standard contract to a UUPS-upgradeable one:

  1. Use Upgradeable Dependencies: Import contracts from @openzeppelin/contracts-upgradeable instead of @openzeppelin/contracts. For example, use ERC20Upgradeable.sol instead of ERC20.sol.

  2. Replace constructor with initialize:

    • The constructor is replaced by a public function, conventionally named initialize.
    • This function must have the initializer modifier (from Initializable.sol) to ensure it can only be run once on the proxy.
    • Calls to parent contract constructors (e.g., ERC20("MyToken", "MTK")) are replaced with calls to their initializer functions (e.g., __ERC20_init("MyToken", "MTK")).
  3. Inherit UUPSUpgradeable: Your contract must inherit from UUPSUpgradeable to get the upgrade logic.

  4. Implement Access Control for Upgrades:

    • You must provide an access control mechanism for the upgrade function. A common choice is OwnableUpgradeable.
    • You must override the _authorizeUpgrade function and protect it with your chosen access control (e.g., the onlyOwner modifier).

Here is a complete example of a V1 contract incorporating these principles:

// contracts/MyTokenV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

contract MyTokenV1 is Initializable, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address initialOwner) initializer public {
        __ERC20_init("MyToken", "MTK");
        __Ownable_init(initialOwner);
        __UUPSUpgradeable_init();

        _mint(initialOwner, 1000 * 10 ** decimals());
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}
}

3. Deploying and Upgrading with Hardhat

The @openzeppelin/hardhat-upgrades plugin makes deployment and upgrades straightforward.

  • Deployment: upgrades.deployProxy(ContractFactory, [initializer_args], { kind: 'uups' })
    • This deploys the implementation contract (MyTokenV1).
    • It deploys the ERC1967Proxy contract.
    • It calls the initialize function on the proxy with the provided arguments.
  • Upgrading: upgrades.upgradeProxy(proxyAddress, NewContractFactory)
    • This deploys the new implementation contract (e.g., MyTokenV2).
    • It performs safety checks (like storage layout compatibility).
    • It calls the upgradeTo() function on the proxy to switch to the new implementation.

Let's see this in action, along with a critical safety feature: storage layout validation.

Deploying More Efficient Upgradeable Contracts

Continuing with the same OpenZeppelin video, let's see how to perform an upgrade and what happens when we make a common but dangerous mistake.

Watch from 00:28:21 to 00:37:17. Focus on two key points: The upgradeProxy function call and how a simple V2 contract is created by inheriting from V1. The demonstration of a storage layout incompatibility error. Understand why changing or reordering state variables corrupts storage and how the plugin automatically prevents you from deploying such a broken upgrade.

4. Critical Security Considerations

While the tools provide many safeguards, secure implementation ultimately rests with you. Here are two non-negotiable security practices for UUPS proxies.

A. Storage Layout Invariance

As demonstrated in the video, the order and type of state variables must never change between upgrades.

  • DO: Add new state variables at the end of your contract.
  • DO NOT: Reorder, remove, or change the type of existing state variables.

If a variable becomes obsolete, you must leave it in place for storage compatibility, perhaps with a comment like // @deprecated - kept for storage layout compatibility.

UUPS Proxies: Tutorial (Solidity + JavaScript)

This point is so critical it's worth reading a clear, written explanation. This comment in an OpenZeppelin forum thread explains the issue perfectly.

In the comments section, find the discussion started by 'David_Hoang' about Contract1, Contract2, and Contract3. Read the explanation of why ct2data must be redeclared in Contract3 to prevent storage corruption. This reinforces the concept shown in the video.

B. The Uninitialized Implementation Vulnerability

The initialize function is public. This means anyone could call it directly on your implementation contract's address and become its owner. This could potentially allow them to execute a selfdestruct or other malicious code if your contract has such vulnerabilities.

The solution is to "lock" the implementation contract's initializer immediately upon deployment. You do this by calling _disableInitializers() in the implementation's constructor.

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    _disableInitializers();
}

The constructor runs only once when the implementation contract is deployed. It has no effect on the proxy's state, making it the perfect place for this one-time setup task. This prevents anyone from ever calling initialize on the implementation contract.

UUPS: Universal Upgradeable Proxy Standard (ERC-1822)

This security measure is vital. The Rareskills article on UUPS provides an excellent explanation of this vulnerability and its solution.

Read the sections 'Vulnerabilities in UUPS' and 'A Checklist for Using UUPS'. Focus on understanding the 'Vulnerability with uninitialized contracts' and the solution using _disableInitializers() in the constructor.

Conclusion

Congratulations! You have now moved from understanding upgradeability in theory to implementing it in practice. You have the tools and knowledge to build flexible, long-lasting smart contract systems using the modern UUPS pattern.

Key Takeaways:

  • Implementation: A UUPS contract inherits from ...Upgradeable contracts, uses an initialize function with the initializer modifier, inherits UUPSUpgradeable, and implements _authorizeUpgrade for access control.
  • Tooling: The @openzeppelin/hardhat-upgrades plugin is essential. It provides deployProxy and upgradeProxy functions that handle the complex deployment and upgrade process while running crucial safety checks.
  • Security Best Practices:
    1. Never alter the storage layout of existing state variables between upgrades. Only append new variables.
    2. Always disable the initializer in the implementation contract's constructor by calling _disableInitializers() to prevent malicious takeovers.

Next Steps:

This lesson concludes our module on advanced Solidity and security. You are now equipped with the patterns needed to write secure, flexible, and efficient smart contracts. In our next module, "Fungible Tokens & DeFi Primitives," we will apply these skills to build the foundational elements of decentralized finance, starting with a deep dive into creating a feature-rich ERC-20 token.

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

Sign up