Eternal Storage without Proxy¶
The first thing to tackle is the loss of data during re-deployment. What comes to mind is to separate logic from storage. The question is how are we doing that?
We go from the left side of this graphics to the right side.
In the Eternal Storage pattern, we move the storage with setters and getters to a separate Smart Contract and let only read/write the logic Smart Contract from it.
This can be a Smart Contract which deals with exactly the variables you need, or you generalize by variable types. Let me show you what I mean by that in the example below.
For sake of simplicity, I will closely take what Elena Dimitrova was using in her Example. But I will greatly simplify this and boil it down to the essence. The Smart Contracts are not therefore remotely complete, but show the most important part to understand what's going on under the hood.
I've ported them to Solidity 0.8.1. Just fyi.
It could look like this:
//SPDX-License-Identifier: MIT
pragma solidity 0.8.1;
contract EternalStorage{
mapping(bytes32 => uint) UIntStorage;
function getUIntValue(bytes32 record) public view returns (uint){
return UIntStorage[record];
}
function setUIntValue(bytes32 record, uint value) public
{
UIntStorage[record] = value;
}
mapping(bytes32 => bool) BooleanStorage;
function getBooleanValue(bytes32 record) public view returns (bool){
return BooleanStorage[record];
}
function setBooleanValue(bytes32 record, bool value) public
{
BooleanStorage[record] = value;
}
}
library ballotLib {
function getNumberOfVotes(address _eternalStorage) public view returns (uint256) {
return EternalStorage(_eternalStorage).getUIntValue(keccak256('votes'));
}
function setVoteCount(address _eternalStorage, uint _voteCount) public {
EternalStorage(_eternalStorage).setUIntValue(keccak256('votes'), _voteCount);
}
}
contract Ballot {
using ballotLib for address;
address eternalStorage;
constructor(address _eternalStorage) {
eternalStorage = _eternalStorage;
}
function getNumberOfVotes() public view returns(uint) {
return eternalStorage.getNumberOfVotes();
}
function vote() public {
eternalStorage.setVoteCount(eternalStorage.getNumberOfVotes() + 1);
}
}
This is a simple voting Smart Contract. You call vote()
and increase a number - pretty basic business logic. Under the hood is the magic.
First we need to deploy the Eternal Storage. This contract remains a constant and isn't changed at all.
Then we deploy the Ballot Smart Contract, which will take the library and the Ballot Contract to do the actual logic.
Under the hood, a library does a delegatecall
, which executes the libraries code in the context of the Ballot Smart Contract. If you were to use msg.sender
in the library, then it has the same value as in the Ballot Smart Contract itself.
Let's test this by voting a few times in the new Ballot Instance:
Let's say we found a bug, because everyone can vote as many times as they want. We fix it and re-deploy only the Ballot Smart Contract (neglecting that the old version still runs and that there is no way to stop it without extra code).
Replace everything with the following code. Highlighted are the actual changes:
//SPDX-License-Identifier: MIT
pragma solidity 0.8.1;
contract EternalStorage{
mapping(bytes32 => uint) UIntStorage;
function getUIntValue(bytes32 record) public view returns (uint){
return UIntStorage[record];
}
function setUIntValue(bytes32 record, uint value) public
{
UIntStorage[record] = value;
}
mapping(bytes32 => bool) BooleanStorage;
function getBooleanValue(bytes32 record) public view returns (bool){
return BooleanStorage[record];
}
function setBooleanValue(bytes32 record, bool value) public
{
BooleanStorage[record] = value;
}
}
library ballotLib {
function getNumberOfVotes(address _eternalStorage) public view returns (uint256) {
return EternalStorage(_eternalStorage).getUIntValue(keccak256('votes'));
}
function getUserHasVoted(address _eternalStorage) public view returns(bool) {
return EternalStorage(_eternalStorage).getBooleanValue(keccak256(abi.encodePacked("voted",msg.sender)));
}
function setUserHasVoted(address _eternalStorage) public {
EternalStorage(_eternalStorage).setBooleanValue(keccak256(abi.encodePacked("voted",msg.sender)), true);
}
function setVoteCount(address _eternalStorage, uint _voteCount) public {
EternalStorage(_eternalStorage).setUIntValue(keccak256('votes'), _voteCount);
}
}
contract Ballot {
using ballotLib for address;
address eternalStorage;
constructor(address _eternalStorage) {
eternalStorage = _eternalStorage;
}
function getNumberOfVotes() public view returns(uint) {
return eternalStorage.getNumberOfVotes();
}
function vote() public {
require(eternalStorage.getUserHasVoted() == false, "ERR_USER_ALREADY_VOTED");
eternalStorage.setUserHasVoted();
eternalStorage.setVoteCount(eternalStorage.getNumberOfVotes() + 1);
}
}
You see, only the Library changed. The Storage is exactly the same as before. But how to deploy the update?
Re-Deploy the "Ballot" Smart Contract and give it the address of the Storage Contract. That's all.
The Storage Contract hasn't changed at all, we don't even need to redeploy it. Just use the one that already exists! You see then that you can vote one last time - so we flag your account, then you get an error (3) in the screenshot.
The original Storage Smart Contract from Elena has a couple more variable types of course, as uint and boolean would not be enough.
While it sounds good, this has some advantages and some disadvantages.
➕ Relatively easy to understand: It doesn't involve any assembly magic at all. If you come from traditional software development, these patterns should look fairly familiar.
➕ Would also work without Libraries, just a Storage Smart Contract running under its own address.
➕ Eliminates the Storage Migration after Contract Updates.
〰️ Address of Contracts change - this can also be good for transparency reasons. E.g. you run an online service and fees change for new signups.
➖ Quite difficult access pattern for variables.
➖ Doesn't work out of the box for existing Smart Contracts like Tokens etc.
It is simple, but a very viable solution - depending on the use case. Sometimes, especially with Smart Contracts, simpler is better. If you want a real-world example of this, checkout the Morpher.com Smart Contracts MorpherState and MorpherToken. They are linked together simply with getters and setters, but have the same effect. They are easy to audit and it's very easy to grasp what's going on under the hood in terms of data storage and retrieval.
Many other project use a proxy pattern where the address of the upgraded Smart Contract stays constant.
That's what we're talking next!