loading...

Evolving ethereum smart contracts in production without dataloss

mrhmp profile image Himanshu Pandey ・6 min read

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.

Alt Text

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:

Alt Text

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:

Alt Text

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.

Alt Text

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:

  1. We are importing the data number solidity file because we are calling the data contract directly from the logic contract.
  2. 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:

Alt Text

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

Discussion

pic
Editor guide