EIP 9000 - Layer3 scaling standard

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

  1. 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)
  2. 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.
  3. 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 uses delegatecallfor invoking functions from implementation contract) and hence selfdestruct 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.

Learning & References:

1 Like

Low level interaction flow

1 Like

From what I see here, it seems to me that this standard or pattern is the idea to combine the old Eternal Storage pattern with EIP-2535 Diamonds.

While the Eternal Storage pattern only works with Solidity data types, diamond storage works with structs. So instead of only dealing with setInt, setString, getInt, getString type functions EIP9000 can deal in custom structs, so setSomeDataInStructA, setSomeDataInStructB, getSomeDataFromStructA, getSomeDataFromStructB etc. Dealing with structs can add a layer of data organization. Also, new functions and state variables and structs can be added to an EIP9000 diamond.

It would be great to see a useful application of this standard.

1 Like