Upgradable smart contracts using the UUPS Proxy Pattern

Solidity

02/14/2022


banner

Note: This tutorial was originally published for LogRocket's Blog.

We all know that one of the most impressive features of the blockchain is its immutablity property. But it is not advantageous in all cases. Imagine a deployed smart contract holding user funds having vulnerability. It is required for the developers to be easily able to fix the bug as early as possible as they do in the web2 applications. But in terms of web3 development, it's not the same case. Traditional smart contract patterns don't allow such hot fixes. Instead the developers need to deploy a new contract every time if they want to add a feature or fix a bug. Well it doesn’t seem like a big issue in the beginning, but it’ll be a huge overhead when the codebase grows. And everytime the data needs to be migrated from the old contract to a new contract to reflect the current state of the protocol/project.

To solve this problem, various upgradeability patterns are introduced, among them “the proxy pattern” is considered as the truest form of upgradeability.

When we speak about upgradeability, it means that the client always interacts with the same contract (proxy) but the underlying logic can be changed (upgraded) whenever needed without losing any previous data.

oz upgradeability

One can argue that via upgradable proxies, a protocol can even change the underlying logic as per their needs (even without the knowledge of their community). There are various methods to prevent that, as DAOs follow Timelocks. But, it's beyond the scope of the article and that itself is a topic for another day.

Types of proxy patterns

Currently, there are three types of proxy patterns:

  • Diamond pattern: EIP-2532
  • Transparent proxy pattern
  • Universal upgradeable proxy standard (UUPS): EIP-1822

What is a diamond pattern?

In a nutshell, a diamond pattern is an upgradeable proxy pattern in which there are multiple logic contracts (facets) instead of one. Whenever a function is called in the diamond proxy contract, it checks the hash table (mapping) to see which facet has the function defined and then delegates the call to that facet. This delegatecall occurs in the proxy’s fallback() method. We can add or remove any number of methods from any facet in a single transaction using a method called diamondCut(). In order to avoid storage collisions, this pattern uses the DiamondStorage technique. It also allows the developers to implement logic in facets, independent of other facets.

What is a Transparent proxy pattern?

As mentioned previously, to implement an upgradeable smart contract, the logic layer (i.e., the implementation contract) is separated from the storage layer (i.e., the proxy contract) and all calls to the proxy contract are delegated to the logic contract.

This method worked just fine until It was fine until a malicious backdoor, proxy selector clashing, was identified and addressed. Proxy selector clashing occurs when two or more methods have identical function signatures in the proxy and logic contract. This can lead to smart contract exploits.

To resolve the clashing, OpenZeppelin introduced the transparent proxy pattern. This pattern allows identical function signatures to exist in the proxy and logic contract, but the delegatecall to the logic contract only occurs if the caller is not a contract admin. Otherwise, the function is invoked in the proxy contract itself if it exists or reverts if not.

What is a UUPS proxy pattern?

The UUPS proxy pattern is similar to the transparent proxy pattern, except the upgrade is triggered via the logic contract rather than from the proxy contract.

There is a unique storage slot in the proxy contract to store the address of the logic contract that it points to. Whenever the logic contract is upgraded, that storage slot is updated with the new logic contract address. The function to upgrade the contracts should be a protected function to avoid unauthorized access. Also, this provides the ability to go completely non-upgradeable gradually as the logic contract can completely remove the upgradeTo() method in the new implementation if needed.

Comparing proxy patterns

The below table compares the pros and cons of the diamond, transparent, and UUPS proxy patterns:

Proxy patternProsCons
Transparent proxy patternComparatively easy and simpler to implement; widely usedRequires more gas for deployment, comparatively
Diamond proxy patternHelps to battle the 24KB size limit via modularity; incremental upgradeabilityMore complex to implement and maintain; uses new terminologies that can be harder for newcomers to understand; as of this writing, not supported by tools like Etherscan
UUPS proxy patternGas efficient; Flexibility to remove upgradeabilityNot as commonly used as it is fairly new; extra care is required for the upgrade logic (access control) as it resides in the implementation contract

When should we use UUPS?

OpenZeppelin suggests using the UUPS pattern as it is more gas efficient. But the decision of when to use UUPS is really based on several factors like the business requirements of the projects, and so on.

The original motivation for UUPS was for deploying many smart contract wallets on the mainnet. The logic could be deployed once. The proxy could be deployed hundreds of times for each new wallet, without spending much gas.

As the upgrade method resides in the logic contract, the developer can choose UUPS if the protocol wants to remove upgradeability completely in the future.

It’s demo time: Smart contract upgrade using UUPS proxy pattern

Enough of the introduction and theory. Let’s set up and deploy an upgradable Pizza contract using the UUPS proxy pattern, leveraging Hardhat and OpenZeppelin’s UUPS library contracts.

Setup with Hardhat and OpenZeppelin

We’ll deploy a simple smart contract called Pizza and upgrade it to PizzaV2 using the UUPS proxy pattern.

As we’ll be using Hardhat for development purposes, you’ll need to have NodeJS and NPM installed in your machine.

Once Node.js is installed and set up, you can install Hardhat globally in your machine from your command line by running the command npm install hardhat -g.

Once Hardhat is installed, you can create new Hardhat projects easily!

Let’s create a fresh new directory for our project:

BASH
mkdir uups-demo && cd uups-demo

Initialize a new Hardhat project by running npx hardhat and choosing the initial config for the project.

hh init

Now Hardhat will install some of the required libraries. Other than that, we’ll require some additional npm modules as well for the UUPS pattern. Run the following command to install the modules.

BASH
npm i @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades @nomiclabs/hardhat-etherscan dotenv --save-dev

Once everything has been installed, the initial directory structure will look something like this:

2 directory structure

N.B., the file names and the contents will be modified as we proceed.

Creating the 'implementation' and 'proxy' contracts:

Create a new file called Pizza.sol inside the contracts directory and add the following code:

SOLIDITY
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
// Open Zeppelin libraries for controlling upgradability and access.
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract Pizza is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public slices;
///@dev no constructor in upgradable contracts. Instead we have initializers
///@param _sliceCount initial number of slices for the pizza
function initialize(uint256 _sliceCount) public initializer {
slices = _sliceCount;
///@dev as there is no constructor, we need to initialise the OwnableUpgradeable explicitly
__Ownable_init();
}
///@dev required by the OZ UUPS module
function _authorizeUpgrade(address) internal override onlyOwner {}
///@dev decrements the slices when called
function eatSlice() external {
require(slices > 1, "no slices left");
slices -= 1;
}
}

This is a simple Pizza contract that has three methods:

  • initialize() — Upgradable contracts should have an initialize method in place of constructors, and also the initializer keyword makes sure that the contract is initialized only once

  • _authorizeUpgrade() — This method is required to safeguard from unauthorized upgrades because in the UUPS pattern the upgrade is done from the implementation contract, whereas in the transparent proxy pattern, the upgrade is done via the proxy contract

  • _eatSlice() — A simple function to reduce the slice count whenever called

Now let’s compile and deploy the Pizza contract.

Before doing that, we have to update the hardhat.config.js file with the following contents:

JS
require("@nomiclabs/hardhat-ethers");
require("@openzeppelin/hardhat-upgrades");
requi
re("@nomiclabs/hardhat-etherscan");
require("dotenv").config();
module.exports = {
solidity: "0.8.10",
networks: {
kovan: {
url: `https://kovan.infura.io/v3/${process.env.INFURA_API_KEY}`,
accounts: [process.env.PRIVATE_KEY],
},
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
},
};

Create a new file called .env and add the following contents:

ENV
PRIVATE_KEY = <<DEPLOYER_PRIVATE_KEY>>
ETHERSCAN_API_KEY = <<ETHERSCAN_API_KEY>>
INFURA_API_KEY= <<INFURA_API_KEY>>

The PRIVATE_KEY is the private key of the deployer wallet. You can grab the INFURA_API_KEY from here and ETHERSCAN_API_KEY from here.

Once the .env file is created, you can compile the contracts by running npx hardhat compile in your terminal.

Now let's deploy our contract. Inside the scripts directory, create a new file called deploy_pizza_v1.js and add the following contents:

JS
const { ethers, upgrades } = require("hardhat");
const SLICES = 8;
async function main() {
const Pizza = await ethers.getContractFactory("Pizza");
console.log("Deploying Pizza...");
const pizza = await upgrades.deployProxy(Pizza, [SLICES], {
initializer: "initialize",
});
await pizza.deployed();
console.log("Pizza deployed to:", pizza.address);
}
main();

Save the file.

Now you can deploy the contracts by running the following command in the terminal:

BASH
npx hardhat run ./scripts/deploy_pizza_v1.js --network kovan

You should see something like this. The address will be different!

deploying contract

The address displayed in the console is the address of the proxy contract. If you visit Etherscan and search the deployer address, you’ll see two new contracts created via two transactions. The first one is the actual Pizza contract (the implementation contract), and the second one is the proxy contract.

In my case, the Pizza contract address is 0x79928a69ada394ad454680d3c4bd2197ad9f7a94.

The proxy contract address is 0x9bBADFcDF4589C6a6179Ee48b7fa7eeeCf4d801c.

You can copy the address of the Pizza contract from Etherscan and verify it by running the command below:

BASH
npx hardhat verify –network kovan <<CONTRACT_ADDRESS>>

The output should be something like this:

output pizza contract etherscan

After deployment, if you’re confused which contract is the proxy or implementation, the proxy contract source code will be already verified on Etherscan (in most cases). The unverified will be the implementation contract!

Once verified, your Etherscan transactions will look like this:

verified etherscan transactions

If you check the Pizza contract in Etherscan, the values like owner, slices, etc. will not be set or initialized because in the proxy pattern, everything is stored and executed in the context of the proxy contract.

So in order to interact with the Pizza contract, you should do it via the proxy contract. To do that, first we need to inform Etherscan that the deployed contract is actually a proxy.

In the Contract tab of the proxy contract, there’ll be a small dropdown above the source code section (on the right side).

proxy verification

Choose “Is this a proxy?” option from the dropdown and then Verify.

proxy etherscan

You can see Read as Proxy and Write as Proxy options in the Contract tab of the proxy contract.

read write as proxy

Now you can interact with the Pizza contract using those options!

Upgrading the contract

After some time passes, let’s say we have to include additional functionality to our Pizza contract. For example, let’s make a simple function to refill slices and a function to return the current contract version.

We can create our PizzaV2 contract. Inside the contracts folder, create a new file called PizzaV2.sol, add the following contents, and save the file:

SOLIDITY
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
import "./Pizza.sol";
contract PizzaV2 is Pizza {
///@dev increments the slices when called
function refillSlice() external {
slices += 1;
}
///@dev returns the contract version
function pizzaVersion() external pure returns (uint256) {
return 2;
}
}

The PizzaV2 contract inherits from the Pizza contract. So all the functions including the two newer functions will be present in the V2 contract.

Once the file is saved, now we can upgrade our Pizza contract to PizzaV2. Inside the scripts directory, create a new file upgrade_pizza_v2.js, add the following contents, and save the file. It is responsible for upgrading the deployed contract:

JS
const { ethers, upgrades } = require("hardhat");
const PROXY = <<REPLACE_WITH_YOUR_PROXY_ADDRESS>>;
async function main() {
const PizzaV2 = await ethers.getContractFactory("PizzaV2");
console.log("Upgrading Pizza...");
await upgrades.upgradeProxy(PROXY, PizzaV2);
console.log("Pizza upgraded successfully");
}
main();

You can run the following command to execute the upgrade:

BASH
npx hardhat run ./scripts/upgrade_pizza_v2.js --network kovan

upgrade success console

N.B., if you face any errors when running the above command, retry it two or three times. It should work.

If you check Etherscan, you can see there’ll be another two transactions from the deployer wallet. The first one is the deployment of the PizzaV2 contract and the second transaction will be the upgradeTo() call in the Pizza contract to perform an upgrade. This makes sure that the proxy contract points to the newly deployed PizzaV2 contract.

You can verify the PizzaV2 contract from the terminal by running:

BASH
npx hardhat verify --network kovan <<PIZZA_V2_ADDRESS>>

If you check the Write as Proxy tab inside the Contract tab of the proxy contract in Etherscan, you can see the newly created view method — refillSlice(), along the older methods. Also, there will be a pizzaVersion() method in the Read as Proxy tab, which confirms that the upgrade is successful!

read as proxy tab

Whoa. 🎉 🎉 We’ve successfully deployed and upgraded contracts using the UUPS proxy pattern!

Closing thoughts

Though there are several advantages to the UUPS pattern, and the recommended proxy pattern is currently the UUPS pattern, there are some caveats that we should be aware of before implementing this into a real-world project.

One of the main caveats is that because the upgrades are done via the implementation contract with the help of upgradeTo method, there’s a higher risk of newer implementations to exclude the upgradeTo method, which may permanently kill the ability to upgrade the smart contract. Also, this pattern is a bit complex to implement when compared to other proxy patterns.

Despite the warnings, UUPS is a very gas-efficient proxy pattern that has several advantages. The code for this project along with some unit tests can be found in the GitHub repo here. Feel free to play around with the code.

Happy coding! 🎊