Why Upgrading OpenZeppelin Smart Contracts from Version 4 to Version 5 is Unsafe
OpenZeppelin is a well-known and trusted company in the blockchain world. It provides secure open-source smart contracts and libraries, which accelerate the blockchain development worldwide. For more than two years, developers from all over the world have been using OpenZeppelin’s v4 contracts to build applications. The v4 contracts were maintained, well-documented, and audited over a long period. However, On the 5th of October, a new journey began with the release the v5 of OpenZeppelin contracts. In this article, we will explain - with an example - why it is not safe to upgrade existing deployments from OpenZeppelin Contract v4 to v5.
TLDR
The v5 of the contracts includes significant changes and improvements. Some contracts and libraries have been restructured, while others have been removed. However, the most crucial reason for the incompatibility is that the v5 contracts of OpenZeppelin integrate a significant change to the storage layer. The contracts in v5 adopt the namespaced storage layout, which is defined in ERC-7201. This change implies that the new logic of v5 contracts will query existing data and write new data in incorrect storage slots, making an upgrade from v4 to v5 not safe. Developers have several options to update an existing contract, such as reinitializing the contract, deploying it as a new proxy, or choosing to continue with updated v4 contracts. Each option has its own unique challenges and requirements.
Prerequisites
We consider the knowledge of upgradeable smart contracts and rudimentary knowledge of the Ethereum Virtual Machine (EVM) storage as a requirement for understanding this article. The following tutorials give a good overview about upgradability of smart contracts:
- https://www.youtube.com/watch?v=JgSj7IiE4jA
- https://www.youtube.com/watch?v=kWUDTZhxKZI
- https://www.youtube.com/watch?v=YJZV9uiDbJI
For the EVM storage layer we recommend the following articles:
Comparing how state variables are stored in OpenZeppelin upgradeable contracts in v4 versus v5
The Ethereum Virtual Machine (EVM) stores state variables in a key-value store with 256-bit keys and 256-bit values. These state variables are stored in a compact, continuous manner. Therefore, the order of the state variables in a smart contract determines their position in the storage.
OpenZeppelin v4 upgradeable contracts primarily use the default approach for storage management. The storage slots for state variables are determined by their sequential order in the contract. Therefore, base contracts often include a ‘storage buffer’ — defined as uint256[49] __gap; — to reserve storage slots. This will allow future versions of that contract to use up those slots without affecting the storage layout of child contracts. This approach of storage handling complicates contract management and upgradeability by increasing the risk of mistakes and storage conflicts, where new upgrades may unintentionally overwrite existing storage slots.
To simplify upgradability and minimize storage conflicts, OpenZeppelin v5 adopted the namespace storage layout, which has been proposed officially as ERC-7201 on June 20, 2023. The ERC-7201 recommends using pseudorandom locations derived from a namespace as the basis for new storage trees in base contracts. The root slot for storage is calculated using a specific formula. This approach does not require changes on the EVM level and can be implemented by grouping state variables within a struct and using assembly language to access specific storage slots directly, as shown in the example:
In the example smart contract x and y are state variables. However, they are not stored on the first and second slots in the storage as default. They will be stored in the storage tree under the given root MAIN_STORAGE_LOCATION = 0x183a…b500, which is calculated using the suggested formula in ERC-7201.
When a v4 upgradeable contract is deployed on the network, the state variables of the contract are initialized, stored, and managed using the default storage pattern based on their order in the contracts. However, if the implementation contract is upgraded using v5 contracts, a critical issue occurs. The new v5 contracts are designed to interact with storage in a different way, using the namespaced storage layout introduced in ERC-7201. As a result, these updated contracts will not recognize or access the original storage slots used by the old contract. This mismatch in storage access will lead to dangerous and incorrect states where variables may not reflect the intended values, and functions may behave unpredictably, leading to loss of funds or compromised security.
Unsafe Upgradability Example
The following ExampleV4 contract uses OpenZeppelin v4 and imports the following contracts Initializable, OwnableUpgradeable, and UUPSUpgradeable. It sets the value of exampleStateVariable, the address of the owner, the address of implementation contract, and the status of the contract to Initialized during the initialization process when it is deployed as proxy.
To check the storage keys of the state variables, we can deploy it as proxy using Remix IDE and then debug it using the Debugger.
As shown in the picture, slot 0x000…000 (slot 0) has the value 0x01 marking the contract as initialized. At Slot 0x000….033 (Slot 51) the, address of the owner of the contract is stored. At 0x000…0c9 (Slot 201) the value 7 of the exampleStateVariable is stored (Note: The variables are not sorted at slots 0, 1, 2 due to the buffer gaps between the contracts). At Slot 0x36089…82bbc the address of the implementation contract stored (Note: The slot of the implementation contract address was already hardcoded in v4 in a similar way to the namespaced storage layout to prevent conflicts).
For demonstration purposes, we will update the implementation contract to ExampleV5 using OpenZeppelin v5 contracts, without introducing new logic. To execute the upgrade, we must import the new v5 contracts, make minor modifications to the original contract to align with the new structure, and then deploy it. Then, we invoke the upgradeTo
function on the proxy contract, providing the address of the new implementation contract as an argument.
After upgrading the contract we can call the two state variables in the proxy contract to check their values.
The value of exampleStateVariable is now 1 and not 7 because the upgraded contract reads the value from the first storage slot, which was the same slot of the initialized status. Furthermore, the owner is the zero address 0x000…000 because the new implemented contract following v5 of OpenZeppelin looks for the address of the owner at another storage slot. Inspecting the v5 contract, we will find that the owner value is read from slot 0x9016…9300, which is empty.
The same issue applies to the Initialization status of the contract. It is read from slot 0xf0c5…6a00 and not from 0x000…000 as it was in v4 contracts
This means our very simple updated contract has incorrect values, is not initialized, and has the zero address as Owner.
At this point, anyone would be able to initialize it again giving the address of owner to control the contract. Even if the original owner still managed to initialize the contract before anyone else the contract will stay in an unsafe state. To explain this we will re-initialize and debug the storage of the upgraded contract using Remix.
As displayed on the above storage snapshot, the original (v4) storage slots at 0x000…0c9, 0x000…033 are not empty. Therefore, new state variables added to the contract could unintentionally use these slots, creating invalid values.
Going Forward with Existing Contracts
For developers, finding the best way to update existing contracts is crucial. There are several options for updating deployed v4 upgradable contracts, some of these options are:
- Reinitializing the Contract: This process involves clearing the storage and migrating the data within the contract. While this approach might be feasible for simple contracts, for complex contracts with dynamic data types such as arrays, strings, and mappings, the storage becomes overly complicated, making migration within the contract impractical, if not impossible.
- Deploying as a Proxy: Similar to the Social Migration approach for non-upgradable contracts, this method involves deploying the new contract as a new proxy with a new address, followed by migrating the data and users to this new contract. Depending on the contract type and existing data, this process can become complicated. Additionally, it requires users’ involvement.
- Updating using v4 Contracts: Likely the most advisable approach is to continue using v4 contracts. This method eliminates the need for data migration or storage restructuring. Developers need to maintain their existing OpenZeppelin v4 contracts, extending or updating only the necessary components for their specific needs when required.
Summary
In Ethereum, upgradeable contracts are complicated due to potential storage conflicts. The introduction of a new storage layout aims to make the upgrade process easier and cleaner. However, old upgradable v4 contracts will face issues with the new layout, as their state variables could be dislocated, leading to an invalid contract state. Therefore, a native upgrade from v4 contracts to v5 is considered as not safe and is not recommended, requiring careful selection of strategies for updating v4 Upgradeable Contracts.