Simple Summary
A primitive standard facilitating eternal upgradeability of smart contracts, by making storage slots dynamic; mitigating the need for redeployment of the proxy in case the storage structure changes
Abstract
Upgrading a storage layout is required in case of a complex project while it’s re architected. The EIP introduces implementation of dynamic storage slots, which can be upgraded to a new one in case of an upgrade. Without actually deploying a new contract altogether.
Motivation
With existing design patterns available while it’s possible to upgrade the code implementation, it is not possible to completely upgrade storage structure. Which leads to re deploying the contracts and introduces a new version of the app
From end user perspective, it is a friction to migrate from old version of app to a new version because of multiple transactions involved
What if we could use the old storage in conjunction with the new storage structure, or we could migrate a user data to newer structure very easily in a single transaction.
With the implementation of this EIP, it would become very convenient to upgrade storage which is a huge problem currently.
Specification
The specification is an addition to the standard EIP-2535 diamond standard design which focuses on putting forward a way for implementing upgradable contracts where the implementation logic is modular where the parts of a contract can be added/replaced/removed while leaving other parts alone. But when it comes to upgrading the storage structure we need a mechanism so that we can maintain the old data points as well and update the implementation in a way that its backward compatible by using the deprecated storage and either migrate to newer storage or utilise both the deprecated storage and the upgraded one.
Before diving into specifics about the storage upgradability, let’s understand how diamond standard works.
How diamond standard works
The exposed contract stores within it a mapping of function selector to implementation contract address. When an external function is called on the exposed contract its fallback
function is executed. The fallback
function finds which implementation contract has the function that has been called and then executes that function from the implementation contract using delegatecall
. The exposed contract’s fallback
function and delegatecall
enable the contract to execute implementation contract’s external function as its own external function. The msg.sender
and msg.value
values do not change and only the exposed contract’s storage is read and written to.
The exposed contract can use a function to add/replace/remove any number of functions from any number of implementation contract in a single transaction. This function updates the mapping of function selector to implementation contract address.
Layer3 scaling standard
For upgrading a storage structure we will store the slot addresses in a special struct we will call it ParentStorage
and then retrieve the slot whenever the specific facet interacts with data store.
Here is a simple example that shows dynamic slot based storage and its use in one of the implementation contract:
// A contract that implements Parent Storage and Implementation contract storage.
library LibA {
// This struct contains state variables related to diamond
struct ParentStorage {
address owner;
}
// This struct contains state variables related to specific implementation contract
struct AppStorageV1 {
bytes32 dataA;
}
// Returns the struct from a specified position in contract storage
// ps is short for ParentStorage
function parentStorage() internal pure returns(ParentStorage storage ps) {
bytes32 storagePosition = keccak256("parent.storage");
// Set the position of our struct in contract storage
assembly {ps.slot := storagePosition}
}
// Returns the struct from a specified position in contract storage
// as is short for AppStorage
function appStorageV1() internal pure returns(AppStorageV1 storage as) {
// The identifier must be unique for a implementation contract
bytes32 storagePosition = keccak256("app.storage.implContractA.V1");
assembly {as.slot := storagePosition}
}
}
// Our implementation contract uses the Parent Storage defined above along with the implementation contract specific storage
contract ImplContractAV1 {
function setDataA(bytes32 _dataA) external {
LibA.ParentStorage storage ds = LibA.parentStorage();
LibA.AppStorageV1 storage as = LibA.appStorageV1();
require(ds.owner == msg.sender, "Must be owner.");
as.dataA = _dataA;
}
function getDataA() external view returns (bytes32) {
LibA.AppStorageV1 storage as = LibA.appStorageV1();
return as.dataA;
}
}
Now that we have unique slot position for our implementation contract in place, we will modify the storage structure and introduce a new slot position for the same and upgrade the contract by using a function which will add the new function from below implementation contract. The function updates the mapping of function selector to implementation contract address.
// A contract that implements Parent Storage and Implementation contract storage.
library LibB {
// This struct contains state variables related to diamond
struct ParentStorage {
address owner;
}
// This struct contains state variables related to specific
struct AppStorageV1 {
bytes32 dataA;
}
// Upgraded struct
struct AppStorageV2 {
uint256 dataA;
}
// Returns the struct from a specified position in contract storage
// ps is short for DiamondStorage
function parentStorage() internal pure returns(ParentStorage storage ps) {
bytes32 storagePosition = keccak256("parent.storage");
// Set the position of our struct in contract storage
assembly {ps.slot := storagePosition}
}
// Returns the struct from a specified position in contract storage
// as is short for AppStorage
function appStorageV1() internal pure returns(AppStorageV1 storage as) {
bytes32 storagePosition = keccak256("app.storage.implContractA.V1");
assembly {as.slot := storagePosition}
}
// Returns the struct from a specified position in contract storage
// as is short for AppStorage
function appStorageV2() internal pure returns(AppStorageV2 storage as) {
bytes32 storagePosition = keccak256("app.storage.implContractA.V2");
assembly {as.slot := storagePosition}
}
}
// Our implementation contract uses the Parent Storage defined above along with the implementation contract specific storage
contract ImplContractAV2 {
function setDataAV2(uint256 _dataA) external {
LibB.DiamondStorage storage ds = LibA.parentStorage();
LibB.AppStorageV2 storage as = LibA.appStorageV2();
require(ds.owner == msg.sender, "Must be owner.");
as.dataA = _dataA;
}
function getDataAV2() external view returns (uint256) {
LibB.AppStorageV2 storage as = LibA.appStorageV2();
return as.dataA;
}
function getDataAV1AndV2() external view returns (bytes32, uint256) {
LibB.AppStorageV1 storage as1 = LibA.appStorageV1();
LibB.AppStorageV2 storage as2 = LibA.appStorageV2();
return (as1.dataA, as2.dataA);
}
}
With above implementation, we will have access to both AppStorageV1
and AppStorageV2
which can be used either for migration or used in conjunction as per the use case.
Diagrams
High level diagram of the associated contracts and storage
// Can only upload one image owing to the new member restrictions. Will add LLD in comments.
Implementation Considerations
- An obvious doubt that might come up is that: We are storing all this data for multiple implementation contracts and even for upgraded contracts in the exposed proxy contract, can there be a point of failure regarding the limit of data we store. The answer is no, because EVM has an Astronomically Large Array for allocating storage (2^256 slots which are 32-bytes wide)
- The next concern would be what if while allocating storage position key we use same key for multiple implementation contracts by mistake. This will cause conflict in storage and unexpected errors in contract. While trying to write to some variable, actual data will be written to some other variable. To accommodate this we will be setting the storage position key dynamically while upgrading the contract. Meaning, while upgrading the contract we will check if implementation contract with same name is already in use or not, if already in use we throw error, asking the developer to rename the contract which in turn keeps the storage position unique.
-
selfdestruct
must not be used while upgrading a contract, as this will cause destruction of whole proxy contract. While executing any logic on implementation contract the context of proxy contract is used (as proxy contract usesdelegatecall
for invoking functions from implementation contract) and henceselfdestruct
will remove the proxy itself from the network.
Rationale
In order to make the upgrade process as smooth and seamless possible it is mandatory to keep all the components involved in a system as modular as possible. Hence the upgradable storage structure will prove to be very helpful.