Evolving ethereum smart contracts in production
Why do we need to evolve smart contracts
There can be multiple reasons for evolving a smart contract, you can find a bug in one of the methods and you need to fix it, you spotted a typo or a formatting issue and that needs to be fixed. However, the one we are interested in for this discussion is adding a new feature in your current contract.
In agile ways of working, we continuously keep evolving the functionalities iterations after iterations, so we need a way to achieve this somehow.
What is the issue in evolving the smart contract
In ethereum smart contracts are deployed as a transaction, the bytecode is extracted for a smart contract and that bytecode is sent as part of the transaction payload. And because of the immutable property of blockchain, you can not change the contract once it is deployed, meaning that once a contract is deployed you can not change anything in the contract, the only choice you are left with is to deploy a new version of the contract.
PROBLEM : If we keep the data of the contract in the state variables, and we want to deploy a new version of the contract then the older state will be lost because we have now deployed a new contract. This is the main problem in evolving smart contracts in live data like in production.
Now consider a very basic contract for this discussion, The Number contract:
ITERATION 1
contract Number {
uint private data;
constructor(uint initVal) public {
data = initVal;
}
function set(uint x) public {
data = x;
}
function get() view public returns (uint retVal) {
return data;
}
}
Now, suppose we want to add a check in the setter method that negative value cannot be added in the contract, so the iteration 2 contract will look like this:
ITERATION 2
contract Number {
uint private data;
constructor(uint initVal) public {
data = initVal;
}
function set(uint x) public {
require(x>0,”Number can not be negative”);
data = x;
}
function get() view public returns (uint retVal) {
return data;
}
}
There can be two different ways to evolve the Number contract keeping the data intact. Keeping the data intact is the main problem that we are trying to resolve.
Maintaining smart contract’s state after deployment
Using delegatecall
The main idea behind this approach is to split the Number contract into two different contracts, one contract I call the proxyNumber contract and the other one is _numberLogicIteration1 _contract.
The main idea in this approach is to make the logic of contract pluggable, and it can be replaced at any point of time, following diagram explain this:
Lets see the code for the proxyNumber contract first:
pragma solidity >=0.4.17 <0.7.0;
contract ProxyNumber {
uint private data;
constructor(uint initVal) public {
data = initVal;
}
function set(uint x , address logicContract) public {
logicContract.delegatecall(abi.encodeWithSignature("set(uint256)",x));
}
function get() view public returns (uint retVal) {
return data;
}
}
The main idea is to use the delegateCall method, according to the official solidity documentation when we use the delegateCall on a contract address then _“the code at the target address is executed in the context of the calling contract and msg.sender
and msg.value
do not change their values”. This means that logicContract’s set method will be invoked but the state will be mutated on the proxyNumberContract.
Let's look at the logicV1Contract:
ITERATION 1
pragma solidity >=0.4.22 <0.7.0;
contract logicV1 {
uint private data;
function set(uint x) public {
data = x;
}
}
The only method here is the setter method for number contracts. Basically we are dynamically loading code from the logicV1 smart contract at run time. Storage, current address and balance still refer to the ProxyNumber contract, only the code is taken from the logicV1 address.
After iteration1, we have to add the validation as mentioned in the introduction, for that we will create one more logic contract like below:
ITERATION 2
pragma solidity >=0.4.22 <0.7.0;
contract NumberProxy {
uint private data;
function set(uint x) public {
require(x>0,"Negative numbers are not allowed");
data = x;
}
}
And from the proxy method all we need to do is change the address for the logic contract:
Separate state and logic contract
This involves separating the smart contract into a data contract which contains the data (variables, structures, mappings etc) with appropriate getters and setters, and a logic contract which contains all of the business logic of how to use and update this data.
This splitting of Number contract allows the logic to be replaced while keeping the data in the same place, allowing for a fully upgradeable system. The address of the data contract can be injected into the logic contract keeping logic and data independent of each other.
The idea behind this approach is that the logic part of a smart contract is more susceptible to change and errors as compared to the data or state variables part of the contract. We need to register the data contract with the logic contract somehow, it can be done by passing the data contract address in the logic contract’s constructor or by using a setter for data contract in the logic contract.
One important thing about this approach is that the logic contract will be importing the abi for the data contract because we need to call the setter method. The delegateCall method used in earlier approaches doesn’t support returning any value so we can not use the delegateCall to return the value, hence we need to initialise the data contract using its address and abi.
Let's first see the code for the Data contract since it will remain same across iterations and is relatively simple:
pragma solidity >=0.4.17 <0.7.0;
contract NumberData {
uint private data;
constructor(uint initVal) public {
data = initVal;
}
function set(uint x) public {
data = x;
}
function get() view public returns (uint retVal) {
return data;
}
}
If you focus on the methods used in this contract, they are only getters and setter methods.
Now lets see the main logic contracts:
ITERATION 1
pragma solidity >=0.4.17 <0.7.0;
import "./Number_Data.sol";
contract NumberLogic {
NumberData private data;
constructor(address dataContractAddr) public {
data = NumberData(dataContractAddr);
}
function set(uint x) public {
data.set(x);
}
function get() view public returns (uint retVal) {
return data.get();
}
}
Few points to notice:
- We are importing the data number solidity file because we are calling the data contract directly from the logic contract.
- We are initialising the data contract from the constructor of the number logic contract using NumberData(dataContractAddr);
Lets see how this will look in iteration 2:
ITERATION 2
pragma solidity >=0.4.17 <0.7.0;
import "./Number_Data.sol";
contract NumberLogic {
NumberData private data;
constructor(address dataContractAddr) public {
data = NumberData(dataContractAddr);
}
function set(uint x) public {
require(x>0,"Negative numbers are not allowed");
data.set(x);
}
function get() view public returns (uint retVal) {
return data.get();
}
}
We can change the logic part of the contract anytime keeping the data still intact because data is kept on a separate data contract. This is depicted below:
That's all folks for today :)
All the code is available on Github, you can comment on this if you have any questions or comments.
Contracts : https://github.com/MrHmP/upgradable-smart-contracts
Top comments (1)
Very cool. There is also EIP-2535 Diamonds which is a standard for building modular smart contract systems that can be extended in production. Introduction here: eip2535diamonds.substack.com/p/int...