Programming proxy contracts with OpenZeppelin | Summary

Introduction

Proxy contracts are an architecture widely used within the Ethereum ecosystem. Basically, in this architecture, there are two main contracts:

  • The proxy contract, which stores the memory and delegate its calls to another contract called logic or implementation contracts
  • This second contract contains all the smart contract logic.

The proxy will delegate the calls from the sender to the implementation.

proxy

Proxy contracts can generally be upgraded to a new implementation.

proxy

For example, the implementation of an ERC-20 proxy will contain all the logic to perform a transfer while the balance of each address is stored in the proxy memory.

To change the behavior of the proxy contract, the proxy admin can deploy a new logic contract and points the proxy to this new one.

But this kind of architecture is not easy to implement, and it is the origin of several bugs:

  • The platform Audius, which was hacked in 2022 for $6 million due to a vulnerability in their proxy contract (storage conflict). Reference: [SharkTeam 2022], [Reutov 2022], [Toulas 2022].
  • Wormhole, who offered a bug bounty record of $10 million for a bug affecting their proxy contract (Uninitialized Proxy), also in 2022. Reference: [Immunefi 2022].

The majority of bug related to proxy concerns:

  • Storage collision between the proxy and the implementation contract
  • Storage collision between an implementation and the new one when performing an upgrade
  • Uninitialized implementation but the severity of this has reduced with the EIP-6780 (deactivate selfdestruct)

OpenZeppelin did a fantastic job of offering proper tools to manage and create proxy contracts.

If you want to use a proxy architecture, this article is a summary of the most important points to think about and check to build proxy contracts on Ethereum/EVM, with a focus on the OpenZeppelin library. For each topic, you can find more information by reading the provided links.

The Solidity interviews Questions by RareSkills contains also several questions related to proxy architecture. You can find my answers for them in two article: Medium and Hard levels

[TOC]

General points

Storage collision

A storage collision can appear in two situations:

a) Proxy and the implementation contract

Storage collision between the proxy and the implementation contract

A storage collision happens when a proxy and its implementation use the same slot to store a value. As a result, when the implementation contract writes to update its variable, it will overwrite in reality the variable used by the proxy.

A solution, used by OpenZeppelin, is to “randomize” slot positions in the proxy’s storage

Reference: [d. OpenZeppelin - Unstructured Storage Proxies]

b) Two implementations

Storage collision between two implementations

This collision happens when a proxy is upgraded to point to a new implementation

In this case, the new implementation overwrites a variable from the previous implementation.

It is very important to keep in mind that you cannot change the order in which the contract state variables are declared, nor their type.

A first solution put in place by OpenZeppelin was to use a state variable named __gap. This is empty reserved space allows to freely add new state variables in the future without compromising the storage compatibility with existing deployments. See OpenZeppelin doc - storage gaps.

Since their version v5.0.0, OpenZeppelin proposed and uses the ERC-7201 - Namespaced Storage Layout. In short, in this case, the storage of each variable is computed with the following formula: \(keccak256(id) - 1\) Example from the ERC: \(keccak256(abi.encode(uint256(keccak256("example.main")) - 1))\;\& ~bytes32(uint256(0xff));\)

bytes32 private constant MAIN_STORAGE_LOCATION =
 0x183a6125c38840424c4a85fa12bab2ab606c4b6d0e7cc73c0c06ba5300eab500;

Reference: [h. OpenZeppelin - Modifying your contracts], p. OpenZeppelin - Storage Collisions Between Implementation Versions, eip-1967, Introducing OpenZeppelin Contracts 5.0

Constructor / initializer

In principle, you can not have a constructor in your implementation contract

If variables are initialized inside the constructor, the proxy has no way to see these values since :

  • The constructor is not stored in the runtime bytecode, but only in the creation bytecode.
  • The implementation contract is not deployed in the context of the proxy.

The solution is to use a public initialize function to initialize the proxy with the different values for each variable.

One exception to this is for immutable variables. Since this value is stored in the contract bytecode instead of the contract storage, you can use and initialize an immutable inside the constructor of the implementation contract.

For example, OpenZeppelin, for the upgradeable implementation of ERC2771 by OpenZeppelin, choose to set the forwarder in the constructor

Reference : c. OpenZeppelin - proxies#the constructor caveat, g. OpenZeppelin - initializers

Initializer / OnlyInitializing

initializer

Since proxied contracts do not make use of a constructor, it’s common to move constructor logic to an external initializer function, usually called initialize. It then becomes necessary to protect this initializer function so it can only be called once. The initializer modifier provided by this contract will have this effect.

onlyInitializing

Modifier to protect an initialization function so that it can only be invoked by functions with the initializer and reinitializer modifiers, directly or indirectly.

In short

  • initializer is used for public-facing functions ;

  • onlyInitializing is used for internal functions.

Reference: frangio 2022, g. OpenZeppelin - initializers

To avoid

Avoiding Initial Values

Variables must be initialized inside the function initialize.

contract MyContract is Initializable {
    uint256 public hasInitialValue;

function initialize() public initializer {
    hasInitialValue = 42; // set initial value in initializer
}

}

Exception

It is possible to initialize value with the constant keyword

uint256 public constant hasInitialValue = 42;

Reference: [f. OpenZeppelin - Avoid initial values in field declarations]

Avoid leaving a contract uninitialized

As seen in the section “Initializer / OnlyInitializing”, a proxy contract calls a specific function from the implementation contract to initialize its variables.

But what happens if this function is called on the implementation contract directly by an attacker?

This can be very bad since this function is not protected by default and can be used to take over the implementation contract.

To prevent the implementation contract from being used, you should invoke the {_disableInitializers} function in the constructor to automatically lock it when it is deployed

Reference : n. OpenZeppelin- Initializable, o. OpenZeppelin - initializing_the_implementation_contract

Potentially Unsafe Operations

The main reference for this section is this article from OpenZeppelin: [Potentially Unsafe Operations]

Delegatecall

As indicated in the OpenZeppelin documentation, use a delegatecallin your implementation contract is an advanced technique and can put funds at risk of permanent loss.

  • For example, before EIP-6780, if the contract can be made to delegatecall into a malicious contract that contains a selfdestruct, then the calling contract will be destroyed.
  • perform a callor a delegatecallcan lead to several vulnerabilities if not properly implemented, as seen for example in the furucombo hack in 2021.

Reference: [a.OpenZeppelin - delegatecall selfdestruct], [i.OpenZeppelin - potentially unsafe operations]

Modifying Your Contracts

When you modify your implementation contract to create a new version, you cannot change the order in which the contract state variables are declared, nor their type.

If you don’t respect this, this will lead to a collision between the storage of the previous implementation and the new one.

Reference: [h.OpenZeppelin - modifying your contracts]

Selfdestruct

Since the Dencun Upgrade and the EIP-6780, selfdestructcan no longer destruct the contract bytecode.

More information in my article.

Before this update, if a direct call, without passing through the proxy, to the logic contract triggers a self destruct, then the logic contract will be destroyed.

The result is catastrophic since all your contract instances will end up delegating all calls to an address without any code.

  • With a transparent proxy, it was still possible to upgrade to a new implementation
  • but with a UUPS proxy, the consequence is much more serious since the logic to upgrade the proxy is coded in the logic contract. Therefore, if the logic contract is destroyed, it is not possible to upgrade the proxy and all the proxy point to the logic contracts are broken forever.

Reference: [a.OpenZeppelin - delegatecall-selfdestruct], [i.OpenZeppelin - potentially unsafe operations]

Deployment & Test

It is important to perform tests on your proxy architecture. For this, you can use the Upgrades Plugins. Plugins are available for Hardhat, Foundry and Truffle

This plugin validates that the contract respects the necessary rules for upgradeability and that the storage layout is correctly preserved.

These plugins are also available to manage deployment and upgrade.

Reference: [OpenZeppelin - staying safe with smart contract upgrades/]

Standalone

If you want to have the possibility to deploy with a proxy or also in standalone mode (without proxy), you should call the initializefunction directly in the constructor for the standalone version like this:

constructor(<constructor argument>)  {
        initialize(< function argument>);
    }

If you do not this, an attacker could front-run you to call the function initialize before you.

However, this remains very unlikely.

Specificity

UUPS proxy

In a UUPS proxy, the logic to upgrade the proxy is coded in the implementation contract. Thus, it is important that this function is indeed implemented in the implementation and correctly protected by an access control.

With OpenZeppelin, your contract must inherit from the contract UUPSUpgradeable and overrides the function _authorizeUpgrade.

Here the function is protected by the modifier onlyOwner.

function _authorizeUpgrade(address) internal onlyOwner {}

This function is internal because it is called by the public function upgradeToAndCall..

Transparent proxy

In a transparent proxy, since the function to upgrade is in the proxy, not in the implementation, it is better to use a dedicated contract ProxyAdminto manage the proxy.

  • If the admin calls the proxy, it can call the upgradeToAndCall function but any other call won’t be forwarded to the implementation.
  • If the admin tries to call a function on the implementation it will fail with an error indicating the proxy admin cannot fallback to the target implementation.

See Openzeppelin doc - ProxyAdmin

Checklist

List of main points to check on a proxy contract based on OpenZeppelin architecture:

  • constructor

    • call to _disableInitializers()to disable the possibility to initialize the implementation
  • Storage gap in all base contracts if ERC-7201 is not used

  • Public initialize function

    • Add initializerto the signature to prevent its initializer function from being invoked twice.
  • Internal _init function

    • Add internal onlyInitializing to the signature
    • Call all the init functions of the contracts parents
    • An _init function does not set any variable, it is role of the unchained init function
  • internal __init_unchained function

    • No call to the parent init function of the contracts parents, it is role of the _init function
    • Set the different variables
  • If UUPS proxy, the function to upgrade the proxy is public/external and protected by access control. With OpenZeppelin, the function _authorizeUpgradeis correctly overriden.

Reference

OpenZeppelin

Documentation

a) FAQ - Can I safely use delegatecall and selfdestruct?

b) OpenZeppelin Hardhat Upgrades AP

c)Proxy Upgrade Pattern - The Constructor Caveat

d) Proxy Upgrade Pattern - Unstructured Storage Proxies4

e) Upgrades Plugins - Test usage

f) Writing Upgradeable Contracts - Avoiding Initial Values in Field Declarations

g) Writing Upgradeable Contracts - Initializers

h) Writing Upgradeable Contracts - Modifying Your Contracts

i) Writing Upgradeable Contracts - Potentially Unsafe Operations

k) OpenZeppelin Truffle Upgrades API

m) Upgrades Plugins

o) Writing Upgradeable Contracts - Initializing the Implementation Contract

p) Storage Collisions Between Implementation Versions

q) blog - OpenZeppelin - staying safe with smart contract upgrades/

Truffle - A Sweet Upgradeable Contract Experience with OpenZeppelin and Truffle

Github

j) github.com/OpenZeppelin - mocks/ERC2771ContextMockUpgradeable.sol

n) github.com/OpenZeppelin - contracts/proxy/utils/Initializable.sol

r) npmjs.com - openzeppelin/hardhat-upgrades

Others

You might also enjoy