Upgradeable smart contracts are essential for maintaining and enhancing blockchain applications. However, the process of upgrading can introduce risks, particularly storage collisions. Storage collisions happen when different storage structures use the same storage for different purposes in proxy scenarios, leading to unintended data overwrites and corrupted states. This article aims to explain these scenarios with practical examples.
How Solidity Works with Storage
In Solidity, each contract has its own storage layout, with variables assigned to specific slots. When a contract is upgraded, it must maintain a compatible storage layout with the previous version to avoid overwriting important data. This includes state variables, mappings, and arrays.
Delegatecall and Its Role in Upgradable Contracts
Delegatecall is most popular for smart contract upgrade scenarios. It allows a contract to execute another contract's code while using its own storage context. This method enables a proxy contract to delegate function calls to various implementations, and facilitate upgrades.
Flow of Delegatecall
- User interacts with the Proxy contract.
- Proxy contract uses delegatecall to forward the request to the implementation contract.
- Implementation contract executes the logic, using the Proxy's storage.
Risks with Delegatecall
- Storage Collisions: Inconsistent storage layouts can lead to data corruption.
- Security Vulnerabilities: Improper access control can allow unauthorized upgrades or changes.
This article examines two storage collision scenarios in depth with visual aids and code references.
Case 1: Proxy Admin and Implementation Contracts Sharing Same Slot
When a smart contract Transparent Upgradable Proxy pattern or any pattern that uses multiple storage structures to store its critical information, their collision can result in devastating effects on the contract's functionalities.
Let's go over the scenario where two separate storage structures of a proxy contract mistakenly use the same starting storage slot for two components
- Proxy Admin Storage
- Implementation Storage
For simplicity, let's assume the common slot is 0
Insights and Diagrams
When the starting slot of admin and implementation are pointing to the same slot number, they can overwrite each other, causing unintended behavior.
+------------------+
| ProxyAdmin |
|------------------|
| - owner |
|------------------|
| + getProxyAdmin |
| + upgrade |
+-------+----------+
|
|
+-------v----------+
| Proxy |
|------------------|
| * ADMIN_SLOT | <----+ Collision
| * IMPLEMENT_SLOT | <----+
| - admin |
| - implementation |
|------------------|
| + upgradeTo() |
| + changeAdmin() |
+----+---------+---+
| |
| delegate| upgrade
| call |
| |
+----v-----+ +---v------+
| CounterV1 | | CounterV2 |
|-----------| |-----------|
| + count | | + count |
| + inc() | | + inc() |
| | | + dec() |
+-----------+ +-----------+
Key Solidity Code:
The following is the core of the problem, as we can see that both slot variables are pointing to slot 0
, therefore
bytes32 private constant IMPLEMENTATION_SLOT = 0;
bytes32 private constant ADMIN_SLOT = 0;
complete code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// Transparent upgradeable proxy pattern
library StorageSlot {
struct AddressSlot {
address value;
}
function getAddressSlot(bytes32 slot)
internal
pure
returns (AddressSlot storage r)
{
assembly {
r.slot := slot
}
}
}
contract Proxy {
bytes32 private constant IMPLEMENTATION_SLOT = 0;
bytes32 private constant ADMIN_SLOT = 0;
constructor() {
_setAdmin(msg.sender);
}
modifier ifAdmin() {
if (msg.sender == _getAdmin()) {
_;
} else {
_fallback();
}
}
function _getAdmin() private view returns (address) {
return StorageSlot.getAddressSlot(ADMIN_SLOT).value;
}
function _setAdmin(address _admin) private {
require(_admin != address(0), "admin = zero address");
StorageSlot.getAddressSlot(ADMIN_SLOT).value = _admin;
}
function _getImplementation() private view returns (address) {
return StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value;
}
function _setImplementation(address _implementation) private {
require(
_implementation.code.length > 0, "implementation is not contract"
);
StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
}
function changeAdmin(address _admin) external ifAdmin {
_setAdmin(_admin);
}
function upgradeTo(address _implementation) external ifAdmin {
_setImplementation(_implementation);
}
function admin() external ifAdmin returns (address) {
return _getAdmin();
}
function implementation() external ifAdmin returns (address) {
return _getImplementation();
}
}
Credit : Updated Version of Proxy Code from SolidityByExample
Initial Deployment
Expected:
Variable Name | Value |
---|---|
Implementation | address(0) |
Admin | 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 |
Actual:
Variable Name | Value |
---|---|
Implementation | 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 |
Admin | 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 |
+-----------------+
| Proxy |
|-----------------|
| IMPLEMENTATION | --+--> Address 0x5B38... (admin address initially)
| | |
|-----------------| |
| ADMIN | --+
+-----------------+
After Upgrading Implementation
Expected:
Variable Name | Value |
---|---|
Implementation | 0xE19b7C663051327aCD91537e71fA1FE2E04De50f |
Admin | 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 |
Actual:
Variable Name | Value |
---|---|
Implementation | 0xE19b7C663051327aCD91537e71fA1FE2E04De50f |
Admin | 0xE19b7C663051327aCD91537e71fA1FE2E04De50f |
+-----------------+
| Proxy |
|-----------------|
| IMPLEMENTATION | --+--> Address 0xE19b... (new implementation address)
| | |
|-----------------| |
| ADMIN | --+
+-----------------+
Therefore, storage collision will either:
- make the contract non-upgradable (due to an invalid admin address)
- or implementation address will be the address of the proxy admin, resulting in the proxy admin forwarding calls to admin's address.
Case 2: Storage Collision during the Upgrade of Implementation Contract
The upgradeable proxy contract uses mappings to store tokenUri
and nftVersions
data, and both mappings share uint256
as key-type.
In the first version of the implementation contract named NFTManager1
, mapping tokenUri
and nftVersions
were at slot n+1
and n+2
respectively. Suppose, during upgrading the implementation contract to NFTManager2
storage layout changed to mapping nftVersions
and tokenUri
declared at slot n+1
and n+2
respectively, where n
is the value of the implementation slot.
In this case, since both mappings share the same key type (uint256
), in NFTManager2
contract tokenUri
will actually be accessing storage data of nftVersions
mapping and vice versa.
Overview Layout
+------------------+
| ProxyAdmin |
|------------------|
| - owner |
|------------------|
| + getProxyAdmin |
| + upgrade |
+--------+---------+
|
|
+--------v---------+
| Proxy |
|------------------|
| * IMPLEMENT_SLOT |
| * ADMIN_SLOT |
| - admin |
| - implementation |
|------------------|
| + upgradeTo() |
| + changeAdmin() |
+----+---------+---+
| |
| delegate| upgrade
| call |
| |
+----v---------+-------------+
| NFTManager1 | NFTManager2 |
|--------------|-------------|
| - tokenUri | - nftVersions | <----+ Collision
| - nftVersions| - tokenUri | <----+ Collision
| + setTokenUri()| + setTokenUri()|
| + setNftVersion()| + setNftVersion()|
+--------------+------------+
Solidity Code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract NFTManager1 {
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
function setTokenUri(uint256 tokenId, string calldata uri) external {
ImplementationStorageSlot1.getAddressSlot(IMPLEMENTATION_SLOT).tokenUri[tokenId] = uri;
}
function setNftVersion(uint256 tokenId, string calldata version) external {
ImplementationStorageSlot1.getAddressSlot(IMPLEMENTATION_SLOT).nftVersions[tokenId] = version;
}
// rest of the code
}
contract NFTManager2 {
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
function setTokenUri(uint256 tokenId, string calldata uri) external {
ImplementationStorageSlot2.getAddressSlot(IMPLEMENTATION_SLOT).tokenUri[tokenId] = uri;
}
function setNftVersion(uint256 tokenId, string calldata version) external {
ImplementationStorageSlot2.getAddressSlot(IMPLEMENTATION_SLOT).nftVersions[tokenId] = version;
}
// rest of the code
}
library StorageSlot {
struct AddressSlot {
address value;
string version;
}
function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
}
library ImplementationStorageSlot1 {
struct AddressSlot {
address value; //slot n+0
mapping(uint256 => string) tokenUri; //slot n+1
mapping(uint256 => string) nftVersions; //slot n+2
}
function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
}
library ImplementationStorageSlot2 {
struct AddressSlot {
address value; //slot n+0
mapping(uint256 => string) nftVersions; //slot n+1 (shld be tokenUri)
mapping(uint256 => string) tokenUri; //slot n+2 (shld be nftVersions)
}
function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
}
contract Proxy {
// -1 for unknown preimage
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
bytes32 private constant ADMIN_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);
function _setImplementation(address _implementation) private {
require(_implementation.code.length > 0, "implementation is not contract");
ImplementationStorageSlot1.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
}
function upgradeTo(address _implementation, string memory _version) external ifAdmin {
_setImplementation(_implementation);
_setVersion(_version);
}
function _setVersion(string memory _version) private {
StorageSlot.getAddressSlot(ADMIN_SLOT).version = _version;
}
// Rest of the TUPS code
}
Sample Inputs
tokenId = 1
tokenUri = "/1.json"
nftVersion = "1.0"
Initial State after Deployment
1. After Deployment
Variable Name | Value |
---|---|
tokenUri | {} |
nftVersions | {} |
2. Upgrade to NFTManager1
and Set Values
- Call
setTokenUri(1, "/1.json")
- Call
setNftVersion(1, "1.0")
Variable Name | Value |
---|---|
tokenUri | {1: "/1.json"} |
nftVersions | {1: "1.0"} |
Diagram:
+-----------------+
| Proxy |
|-----------------|
| - tokenUri |
| - nftVersions |
|-----------------|
| * IMPLEMENT_SLOT| -> NFTManager1
| * ADMIN_SLOT |
|-----------------|
| + upgradeTo() |
| + changeAdmin() |
+-----------------+
|
v
+-----------------+
| NFTManager1 |
|-----------------|
| - tokenUri | -> {1: "/1.json"}
| - nftVersions | -> {1: "1.0"}
+-----------------+
3: Upgrade to NFTManager2
and Set Values
- Call
setTokenUri(1, "/1.json")
- Call
setNftVersion(1, "1.0")
Variable Name | Value |
---|---|
tokenUri | {1: "1.0"} (Collision) |
nftVersions | {1: "/1.json"} (Collision) |
Diagram:
+-----------------+
| Proxy |
|-----------------|
| - tokenUri |
| - nftVersions |
|-----------------|
| * IMPLEMENT_SLOT| -> NFTManager2
| * ADMIN_SLOT |
|-----------------|
| + upgradeTo() |
| + changeAdmin() |
+-----------------+
|
v
+-----------------+
| NFTManager2 |
|-----------------|
| - nftVersions | -> {1: "/1.json"} (Collision)
| - tokenUri | -> {1: "1.0"} (Collision)
+-----------------+
Explanation
- Proxy: Delegates calls to the implementation contract, that defines storage slots for
tokenUri
andnftVersions
. - Implementation Contracts:
- NFTManager1: Uses mappings for
tokenUri
andnftVersions
. - NFTManager2: Uses mappings in an opposite order, causing a storage collision.
- NFTManager1: Uses mappings for
- Collision: Due to different slot layouts in both versions of the implementation contracts,
NFTManager2
contracttokenUri
will be accessing the storage data ofnftVersions
mapping, andnftVersions
mapping oftokenUri
mapping.