Deep Dive: Upgradeable Smart Contracts

This post is a deep dive into the upgrade patterns for Smart Contracts targeted for Ethereum Virtual Machines (EVM). To understand this post you would need at least a beginner level understanding of Solidity, Hardhat and Ethers.js library. The topics we will explore are:

  • Upgradable Smart Contracts: The Why?
  • Migration vs Proxy Pattern
  • Understanding delegatecall()
  • Security considerations with proxy pattern
    • State storage clashes
    • Function selector clashes
  • Proxy patterns:
    • Transparent Proxy
      • EIP-1967
    • UUPS (Universal Upgradeable Proxy Standard)
  • Epilogue

Upgradable Smart Contracts: The Why?

Smart contracts are immutable i.e., their storage (state) can change but the deployed byte-code can never be modified. This can be problematic in case a code update is needed for bug fixes or for releasing new features. Fortunately, as we will see soon, there still are ways to write “upgradable” smart contracts while preserving their immutability.

Migration vs Proxy Patterns

Since we can’t change an already deployed contract, one way to approach upgrade is by deploying a new version and then migrating all users to the new instance. However, it might be difficult to inform and nudge every user to switch over to the new version. This is called a Migration and is how all the software updates are performed in general.
Another approach is to have your users interact with a “proxy” contract that forwards all user requests to another “implementation” contract. The address of this implementation contract is saved in the proxy contract’s state and can be changed whenever a newer version of the implementation is deployed. This Proxy pattern routes all user transaction to the latest version without the need for any action from the users.

Lets dive deeper into proxies to understand how they work.

A simple proxy example

As a start, lets look at this bare-bones proxy pattern implementation with 3 contracts:

  • Implementation.sol: An implementation contract with an increment() function that adds 1 to the value in Slot0 of the proxy contract.
  • Proxy.sol: Our proxy contract with a fallback() to route all user requests to the implementation contract.
  • User.sol: This mimics a user that only interacts with the proxy contract.
// Implementation.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract Implementation
{
    // Refers to slot0 in proxy storage
    uint public x = 0;

    function increment() public
    {
        x++;
    }
}
// Proxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract Proxy
{
    uint public i = 0; // saved in slot 0
    address implementationContract = 0xd9145CCE52D386f254917e481eB44e9943F39138;

    fallback() external
    {
        // Pass along the function call to implementation contract
        implementationContract.delegatecall(msg.data);
    }
}
// User.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract User
{
    address proxyContract = 0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8;

    function callProxy() public
    {
        // calls increment() on proxy contract which actually resides in the implementation contract
        proxyContract.call(abi.encodeWithSignature("increment()"));
    }
}

The increment() function call by the user is handled by the fallback() in the proxy and fallback() then calls increment() in the implementation contract. The user only interacts with the proxy contract and might even not be aware of the existence of the implementation contract. If a new version of implementation is released, the proxy contract simply need to update its address. To keep the discussion simple however, this upgrade functionality has not been provided here.

Understanding delegatecall()

Delegatecall() is the cornerstone of proxy implementation on Ethereum blockchain. It is similar to address.call() but carries an important distinction: using delegatecall() to invoke a function results in the code being run in the caller’s context. In other words, If contract A issues a delegatecall() to a function foo() in contract B then foo() will modify the state of contract A. So in the example above, Implementation.increment() increases x by 1 but due to the nature of delegatecall, state variable Proxy.i is incremented instead. If the developer is not careful, this behaviour can potentially result in a storage clash where an inadvertent state change can occur. An another potential problem proxies face is the function selector clash. Both of these are discussed in detail below.

Learn more about delegatecall

Security considerations with proxy pattern

There are 2 well known security issues that can haunt proxy implementations: State storage clash and the Function selector clash. Lets see examples for both below.

State storage clashes: EVM stores the values of state variables in storage slots that start at an index of 0. For example, the state variable “i” in Proxy contract will be saved in Slot0 and the variable “implementationContract” will go in Slot1. If the developer is not careful, this behaviour can cause incorrect state variable to be updated from the implementation contract. To show this in action, I have modified the proxy implementation example we saw above.

Learn more about storage slots

// Implementation.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract Implementation
{
    // Refers to slot0 in proxy storage
    uint public x = 0;
    uint public y = 0;

    function increment() public
    {
        x++;
    }

    // New function - modifies slot1
    function Set_Y(uint val) public
    {
        y = val;
    }
}
// Proxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract Proxy
{
    uint public i = 0; // saved in slot 0
    address public implementationContract = 0x7EF2e0048f5bAeDe046f6BF797943daF4ED8CB47;

    fallback() external
    {
        // Pass alogn the function call to implementation contract
        implementationContract.delegatecall(msg.data);
    }
}
// User.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract User
{
    address proxyContract = 0xDA0bab807633f07f013f94DD0E6A4F96F8742B53;

    function callProxy() public
    {
        // calls increment() on proxy contract which actually resides in the implementation contract
        proxyContract.call(abi.encodeWithSignature("increment()"));
    }

    // Sends an address as a uint256
    function HijackProxy() public
    {
        // Address where a malicious contract is deployed
        address maliciousProxy = 0xd9145CCE52D386f254917e481eB44e9943F39138;
        // An address can't be converted to uint256 directly
        proxyContract.call(abi.encodeWithSignature("Set_Y(uint256)", uint256(uint160(address(maliciousProxy)))));
    }    
}

I have added a new function Set_Y(uint val) to the implementation contract that can set slot1 of proxy contract to any uint256 value supplied from the user contract’s HijackProxy() function. An attacker can exploit this functionality to update the implementationContract variable to an address where a malicious contract is deployed. This malicious contract can easily cause problems like incorrectly updating other state variables (e.g. a state variable carrying net token balance) or do a DoS attack (e.g. by using revert(), or worse, assert(false)). This is why it is very important to review the storage slot usage once a new version of any implementation contract is released.

Function selector clash: When we call a function contained in a smart contract, we have to specify its name along with any arguments (e.g. contract.foo(“Hello World”)) in a high level language like Solidity. However after a contract is compiled into bytecode, the EVM only uses the first 4 bytes of Keccak256 hash of the function signature to refer to the function.

keccak256(“foo(string)”) = f31a6969fc2f2e0b01964045ad21a28ad3ee38d276e1e6cf5b80124e63ba8190
So the EVM refers to this function as f31a6969 and not by its actual name.

A 4 byte selector can produce 2^32 selectors but it is still possible to find same selector for 2 different function signatures – this is called a function selector clash. For example all functions listed below share the same first 4 bytes in their keccak256 hash:

keccak256("remove_good(uint256[],bytes8,bool)") = dd62ed3efbff55a676c6b38f97e05e8c0745d88aad0427103a05eb8dfaac042e

keccak256("allowance(address,address)") = dd62ed3e90e97b3d417db9c0c7522647811bafca5afc6694f143588d255fdfb4

keccak256("_func_5437782296(address,address)") = dd62ed3ef08f298ffce2b1961513ce157cd8f3d7ef39c01e8de2a52ba87c478e

First 4 bytes for all above: dd62ed3e

Suppose we have “allowance(address,address)” in an implementation contract that handles tokens. Now if a developer sneaks in a malicious function “_func_5437782296(address,address)” in a new release of the proxy contract, the person intending to run allowance(address,address) will end up executing _func_5437782296(address,address) and potentially loose all tokens.

Learn more about function selector clash

Proxy Patterns

Due to (but not limited to) the issues discussed above, the good people at OpenZeppelin have researched and developed two proxy patterns that are the topics of our next discussion: Transparent Proxy and UUPS (Universal Upgradeable Proxy Standard). These patterns offer standard and safe ways for developers to create upgradable smart contracts. They only carry minor (but important) differences among them but UUPS is the newer pattern and is recommended by OpenZeppelin over Transparent. There are other upgrade patterns being explored and used by the developers (e.g. diamond pattern) but they are out of scope of this post as Transparent and UUPS patterns are the prominently used ones.

Transparent Proxy Pattern

Transparent proxy pattern attempts to solve the function selector clash problem by making a distinction between the functions that can be called by an admin vs other (non-admin) users:

  • A admin user can only call functions in the proxy contract.
  • All general (non-admin) users can only call functions in the implementation contract.

This way the proxy is “transparent” to the general user who only sees the implementation contract. So only admin has access to “proxy management” related functionality in the proxy (such as changing implementation contract’s address) while the implementation contract contains business logic meant for the general users.

Let’s write a contract and then upgrade it via the Transparent proxy pattern. Below we have an implementation contract (implementation_v1) that we will deploy using transparent proxy mechanism to the Goerli network and then we will upgrade it to implementation_v2.

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

contract implementation_v1
{
    uint i;

    function initialize(uint _i) public
    {
        i = _i;
    }

    function inc1() public
    {
        i++;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract implementation_v2
{
    uint i;

    function inc2() public
    {
        i+=2;
    }
}

We will first write the deployment script:

// deploy.js

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

const main = async() => {    
    const implementation_v1_Contract = await ethers.getContractFactory("implementation_v1")
    const impl_v1 = await upgrades.deployProxy(implementation_v1_Contract, [10], {initializer: "initialize"})
    await impl_v1.deployed();
    console.log("implementation_v1 deployed at:", impl_v1.address);
}

main()

Then we use this command to deploy our contract to the Goerli test network:

npx hardhat run --network goerli scripts/deploy.js

Now if we go to Etherscan to find our deployment transaction, we see 3 transactions instead of just 1.

https://goerli.etherscan.io/address/0x16b52a6cbd62104787a15df4ef54ea2fe3ec4a9a

This is because these 3 contracts were created by Hardhat:

The TransparentUpgradeableProxy contract acts as the proxy to the implementation_v1 contract. Users will send their transactions to this proxy and the fallback() inside it will delegatecall() them to implementation_v1. The ProxyAdmin contract acts as the admin for TransparentUpgradeableProxy contract and so any proxy management functionality (such as changeProxyAdmin() and upgrade()) will have to be requested via this contract. Lets look at the TransparentUpgradeableProxy and ProxyAdmin in detail.

TransparentUpgradeableProxy Contract

As we discussed earlier, this is the proxy that accepts and processes all transactions for the implementation_v1 contract. If you look at the contract code at 0xc838E2CE51525245Db372f4e9100e7Faf4A1db8E you will find that the contract deployed there is of type TransparentUpgradeableProxy. TransparentUpgradeableProxy inherits from the contract ERC1967Proxy which in turn inherits from below:

  • Proxy: Contains the fallback() that forwards all user (non-admin) function calls to implementation_v1 contract via delegatecall()
  • ERC1967Upgrade: Defines the storage slot numbers for storing implementation contract address and TransparentUpgradeableProxy’s administrator address as described in EIP-1967. This EIP introduced some important standards for upgradable contracts and we discuss them below.
EIP-1967

The proxy contract stores address of the implementation contract in one of its storage slots. If any future release of the implementation contract overwrites this slot, it can create potential security issues as we saw earlier. To address this, EIP-1967 proposes to use fixed storage slots for storing the implementation contract address and the admin’s address:

/**
 * @dev Storage slot with the address of the current implementation.
 * This is the keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1, and is
 * validated in the constructor.
 */
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
/**
 * @dev Storage slot with the admin of the contract.
 * This is the keccak-256 hash of "eip1967.proxy.admin" subtracted by 1, and is
 * validated in the constructor.
 */
bytes32 internal constant _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

(EIP-1967 also talks about Beacon contracts but it is out of scope of this post)

The storage slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc is where the implementation contract’s address is stored. This value was obtained simply by calculating Keccak256 of the string “eip1967.proxy.implementation” and subtracting 1 from it. Why deduct 1 you ask? Because the “preimage” of 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbd is already known – it is “eip1967.proxy.implementation”. By subtracting one, we get 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc – a value whose preimage is not known. How much security this additional step provides is still open to debate though.

Similarly, the admin address is stored at 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 which is the keccak256 hash of the string “eip1967.proxy.admin” subtracted by 1. In our example, this slot will store the address of the ProxyAdmin contract.

In addition to the storage slots, EIP-1967 also provides below events that are emitted when either implementation contract address or admin’s address change. These events should be monitored to catch any unintended changes in these two values.

/**
 * @dev Emitted when the implementation is upgraded.
 */
event Upgraded(address indexed implementation);
/**
 * @dev Emitted when the admin account has changed.
 */
event AdminChanged(address previousAdmin, address newAdmin);

EIP-1967 not only aims to prevent storage clash problem by using standard slots for storing important addresses, it also enables services like Etherscan to reliably identify and treat proxy contracts as such. If we look at our TransparentUpgradeableProxy on Etherscan, we see it provides 2 ways to interact with the contract: Read Contract and Write Contract and under Write Contract, it lists the functions contained within the TransparentUpgradeableProxy contract.

Clearly the functions like changeAdmin() and upgradeTo() can only be called by the ProxyAdmin contract. As a non-admin user, we are interested in calling the functions inside the implementation_v1 contract but to do that we will have to ask Etherscan to recognize TransparentUpgradeableProxy as a proxy contract. To do that we will have to go to “Contract tab > Code” and then click on “More Options > Is this a proxy?” option.

Doing this makes Etherscan to check if the storage slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc contains any value. If it does, that value is treated as the address of an implementation contract and its functions are made available under “Read as Proxy” and “Write as Proxy” tabs:

Contract ERC1967Proxy is OpenZeppelin’s EIP-1967 implementation and our TransparentUpgradeableProxy inherits from it which provides TransparentUpgradeableProxy access to not only the standard storage slots but also to upgrade mechanism via the ERC1967Upgrade contract.

ProxyAdmin Contract

Storage slot 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 of the TransparentUpgradeableProxy contract contains the address of the ProxyAdmin contract. This means any request to upgrade the proxy or change its admin should come from the ProxyAdmin contract. The ProxyAdmin contract inherits from Ownable which defines the onlyOwner modifier. This modifier forces the caller to be the owner i.e. the account that deployed the ProxyAdmin contract.

// from Ownable.sol
/**
 * @dev Throws if called by any account other than the owner.
 */
modifier onlyOwner() {
    require(owner() == _msgSender(), "Ownable: caller is not the owner");
    _;
}

This modifier is used by ProxyAdmin to only allow the owner to update the proxy or change the administrator:

// from ProxyAdmin.sol
/**
 * @dev Changes the admin of `proxy` to `newAdmin`.
 *
 * Requirements:
 *
 * - This contract must be the current admin of `proxy`.
 */
function changeProxyAdmin(TransparentUpgradeableProxy proxy, address newAdmin) public virtual onlyOwner {
    proxy.changeAdmin(newAdmin);
}

/**
 * @dev Upgrades `proxy` to `implementation`. See {TransparentUpgradeableProxy-upgradeTo}.
 *
 * Requirements:
 *
 * - This contract must be the admin of `proxy`.
 */
function upgrade(TransparentUpgradeableProxy proxy, address implementation) public virtual onlyOwner {
    proxy.upgradeTo(implementation);
}

Implementation_v1 Contract

This contract is pretty simple but carries an important distinction – it has no constructor. Instead it has what we call an initializer:

function initialize(uint _i) public
{
    i = _i;
}

Why is there no constructor? A constructor initializes the state variables of the contract it is defined in. An implementation contract does not use its state variables but rather the state variables of the proxy contract and that’s why a constructor serves no purpose. Instead we use an initializer that serves the same purpose except it is called once during deployment. If you look at the deployment script (deploy.js), we provide “initialize” as the name of our initializer while deploying our contracts:

const impl_v1 = await upgrades.deployProxy(implementation_v1_Contract, [10], {initializer: "initialize"})

The important thing to remember is that an initializer is just a regular function and can cause problems with state if someone calls it a 2nd or more times. Imagine a state variable holding some important information (e.g. net token balance) being reset to zero simply because someone called the initializer. To address this problem, Openzeppelin has provided a base contract “Initializable” with an “initializer” modifier that makes sure a function can only be invoked only once. So our implementation_v1 contract would change to this:

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

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

contract implementation_v1 is Initializable 
{
    uint i;

    function initialize(uint _i) public initializer
    {
        i = _i;
    }

    function inc1() public
    {
        i++;
    }
}

Now that we have an understanding of how some of the things are working under the hood, lets proceed to the next step and upgrade the proxy to make it point from implementation_v1 to implementation_v2.

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

const main = async() => {    
    
    // Address of TransparentUpgradeableProxy
    const proxyAddress = "0xc838E2CE51525245Db372f4e9100e7Faf4A1db8E";

    const _IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
    
    // Lets read the value in proxy's implementation slot before the upgrade
    var implementationAddress = await ethers.provider.getStorageAt(proxyAddress, _IMPLEMENTATION_SLOT)
    console.log("Implementation contract address before upgrade:", implementationAddress)   
        
    // Perform the upgrade
    const implementation_v2_Contract = await ethers.getContractFactory("implementation_v2")
    await upgrades.upgradeProxy(proxyAddress, implementation_v2_Contract)
    console.log("Upgrade completed");
}

main()
Output

root@ununtu-vm:/home/user/Desktop/hardhat/proxy/transparent1# npx hardhat run --network goerli scripts/upgrade.js
Implementation contract address before upgrade: 0x000000000000000000000000c83af6336b3b7cbf2968fd38aa20af3935e28850
Upgrade completed

When I read the value stored at _IMPLEMENTATION_SLOT again, I can see the address of implementation_v2 contract:

Implementation contract address after upgrade: 0x000000000000000000000000b90815d9f20e881eb62c5340d6fa67d01d6c88fa

Etherscan also shows the same thing however it does want me to verify the new contract:

We can verify the new contract with this command:

root@ununtu-vm:/home/user/Desktop/hardhat/proxy/transparent1# npx hardhat verify "0xb90815d9f20e881eb62c5340d6fa67d01d6c88fa" --network goerli
Nothing to compile
Successfully submitted source code for contract
contracts/implementation_v2.sol:implementation_v2 at 0xb90815d9f20e881eb62c5340d6fa67d01d6c88fa
for verification on the block explorer. Waiting for verification result...

Successfully verified contract implementation_v2 on Etherscan.
https://goerli.etherscan.io/address/0xb90815d9f20e881eb62c5340d6fa67d01d6c88fa#code

Etherscan shows the new function inc2() after verification:

So far we have understood how to deploy a simple proxy and upgrade it using the Transparent proxy mechanism. This is a good time to see a more practical application so lets write an upgradable ERC20 contract.

An upgradable ERC20 token

Now we will look at a more practical example of how to write a simple upgradable ERC20 token contract. Below we are creating an upgradable ERC20 token named ERC20_v1 with a token symbol of XYZ:

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

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

contract ERC20_v1 is Initializable, ERC20Upgradeable, OwnableUpgradeable
{
    function initialize() initializer public {
        __ERC20_init("ERC20_v1", "XYZ");
        __Ownable_init();
    }

    function mint(address to, uint256 amount)  public onlyOwner {
        _mint(to, amount);
    }
}

Our ERC20_v1 contract derives from Initializable that lets it use “initializer” modifier on the initialize() function. This ensures initialize() will only be called once i.e. during the creation of proxy. It also derives from ERC20Upgradeable and OwnableUpgradeable contracts which are the “consturctor-free” versions of ERC20 and Ownable contracts made by Openzeppelin. Instead of constructors, they have initializers (that do the same thing as constructors) that need to called from within the initialize() function. Openzeppelin provides such upgradable versions for many other commonly used contracts as well. For example:

ERC721 –> ERC721Upgradeable
IERC1155 –> IERC1155Upgradeable
ReentrancyGuard –> ReentrancyGuardUpgradeable

A real, production-grade ERC20 token would have other features like burn() and security considerations like pause() as well. Suppose the developer adds a burn() function to this token, she would simply have to upgrade the proxy to the new version of the token (ERC20_v2) by using upgrades.upgradeProxy().

This completes our discussion of Transparent proxies, we will look at the UUPS pattern next.

UUPS (Universal Upgradeable Proxy Standard)

Transparent proxy pattern was introduced when the gas prices were much lower than where they are today. Due to the way they are designed to work, their use comes with some gas cost “overheads” because:

  • An additional admin contract is created along with the proxy and implementation during deployment
  • For each transaction, the proxy contract has to check if msg.sender is same as admin’s address

To address the high gas costs, Openzeppelin has provided an implementation of the UUPS proxy pattern which was first introduced in EIP-1822. The idea behind UUPS is to simply do away with an Admin contract and move the upgrade mechanism from proxy contract to the implementation contract. So unlike the Transparent pattern, in UUPS it is the implementation contract that is responsible for implementing an upgrade mechanism. This allows the developer to customize the upgrade mechanism in case they want to add any features like, say, a voting mechanism. This is not possible with the Transparent pattern.

Learn more about UUPS

Now lets write an ERC20 token contract and upgrade it using the UUPS base contract UUPSUpgradeable and the Hardhat “Upgrades” plugin. We are going to reuse the ERC20_v1 contract from our previous example, albeit this time we are deriving from UUPSUpgradeable base contract.

// ERC20_v1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

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

contract ERC20_v1 is Initializable, ERC20Upgradeable, UUPSUpgradeable, OwnableUpgradeable
{
    function initialize() initializer public {
        __ERC20_init("ERC20_v1", "XYZ");
        __Ownable_init();
    }

    function mint(address to, uint256 amount)  public onlyOwner {
        _mint(to, amount);
    }

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

}

Lets deploy it on the Goerli testnet. Note how we have to specify kind: “uups” to instruct the Openzepplin’s Upgrades plugin to treat this as a UUPS proxy.

// deploy.js

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

const main = async() => {    
    const erc20_v1_Contract = await ethers.getContractFactory("ERC20_v1")
    const erc20_v1 = await upgrades.deployProxy(erc20_v1_Contract, {initializer: "initialize", kind: "uups"})
    await erc20_v1.deployed();
    console.log("erc20_v1 deployed at:", erc20_v1.address);
}

main()
Output:

root@ununtu-vm:/home/user/Desktop/hardhat/proxy/uups1# npx hardhat run --network goerli scripts/deploy.js
Downloading compiler 0.8.15
Compiled 15 Solidity files successfully
erc20_v1 deployed at: 0xdf9FaeED6d9B23d7769f5A4141d08FE88D046272

The deployment process triggers two transactions to deploy:

The UUPS proxy is simply a ERC1967Proxy contract which will delegate all function calls (including upgrade requests) to the ERC20_v1 implementation contract. This is in contrast to the Transparent proxy where an upgradeTo() function was available in the TransparentUpgradeableProxy contract.

The ERC20_v1 contract inherits from the usual suspects: Initializable, ERC20Upgradeable and OwnableUpgradeable that we have discussed before. ERC20_v1 also inherits from the UUPSUpgradeable base contract which contains an upgradeTo() to handle upgrades on the implementation side.

    function upgradeTo(address newImplementation) external virtual onlyProxy {
        _authorizeUpgrade(newImplementation);
        _upgradeToAndCallUUPS(newImplementation, new bytes(0), false);
    }

This upgradeTo() function calls _authorizeUpgrade() which is an abstract function so the developer is required to provide an implementation. Using the onlyOwner modifier on _authorizeUpgrade() will ensure that only the contract owner can perform the upgrade.

    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {
    }

The way to upgrade a UUPS proxy is very similar to the Transparent pattern. Lets add a burn() to our token and upgrade it to ERC20_v2.sol. The upgrade script and ERC20_v2.sol are provided below.

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

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

contract ERC20_v2 is ERC20Upgradeable, UUPSUpgradeable, OwnableUpgradeable
{

    function mint(address to, uint256 amount)  public onlyOwner {
        _mint(to, amount);
    }

    function burn(address to, uint256 amount)  public onlyOwner {
        _burn(to, amount);
    }

    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
// upgrade.js

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

const main = async() => {

    const proxyAddress = "0xdf9FaeED6d9B23d7769f5A4141d08FE88D046272";

    // Perform the upgrade
    const ERC20_v2_Contract = await ethers.getContractFactory("ERC20_v2")
    await upgrades.upgradeProxy(proxyAddress, ERC20_v2_Contract)
}

main()

This completes our discussion of the UUPS pattern.

Epilogue

Both Transparent and UUPS pattern offer solid ways to add upgradability to your project however doing so well requires an in-depth knowledge of how proxies work. A developer would be well advised to understand all the base contracts provided by Openzeppelin rigorously to avoid any costly mistakes. I believe the usage of upgradable contracts will ever increase along with introduction of new upgrade patterns making this an interesting space to watch from both a developer’s and pentester’s perspective.

Leave a Comment