paint-brush
Smart Contract Versioningby@vasa
3,124 reads
3,124 reads

Smart Contract Versioning

by Vaibhav SainiFebruary 27th, 2020
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Using OpenZeppelin CLI to easily write/manage "upgradable" smart contracts. Smart contracts in Ethereum are immutable by default. There are a lot of examples in which millions of dollars worth of Ether were stolen/hacked, which could be saved if we could update the contracts. There can be several ways we can upgrade our contracts. The most obvious way is to manually migrate all states from the old contract to the new contract. This seems to work, but has several problems. Using a proxy contract with an interface where each method delegates to the implementation contract is better.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image -  Smart Contract Versioning
Vaibhav Saini HackerNoon profile picture

How to Write Upgradable Smart Contracts

Smart contracts are immutable, by design. On the other hand, software quality heavily depends on the ability to upgrade and patch source code to produce iterative releases. Even though blockchain-based software profits significantly from the technology’s immutability, still a certain degree of mutability is needed for bug fixing and potential product improvements.

  1. In this post, we are going to learn:
  2. Why do we need to upgrade smart contracts?
  3. Understanding how upgrades work under the hood?
  4. Using OpenZeppelin CLI to easily write/manage "upgradable" smart contracts.
  5. Upgrading Contracts Programmatically using the OpenZeppelin Upgrades library.
  6. A few Limitations & Workarounds of Upgradable Contracts

If you are just looking for a way to write upgradable contracts and don't want to go through "how this all works", then just head over to the 3rd section.

In case you get stuck somewhere or have any doubts, let us know here.

Why do we need to upgrade?

Smart contracts in Ethereum are immutable by default. Once you create them there is no way to alter them, effectively acting as an unbreakable contract among participants.

However, there are several scenarios where we wish if there was a way to upgrade the contracts. There are a lot of examples in which millions of dollars worth of Ether were stolen/hacked, which could be saved if we could update the smart contracts.

How do upgrades work under the hood?

There can be several ways we can upgrade our contracts.

The most obvious way will be something like this:

  1. Create & deploy a new version of the contract.
  2. Manually migrate all states from the old contract to the new contract.
  3. This seems to work, but has several problems.

  1. Migrating the contract state can be expensive.
  2. As we create & deploy a new contract, the contract address will change. So you would need to update all contracts that interacted with the old contract to use the address of the new version.
  3. You would also have to reach out to all your users and convince them to start using the new contract and handle both contracts being used simultaneously, as users are slow to migrate.

A better way is to use a proxy contract with an interface where each method delegates to the implementation contract (which contains all the logic).

delegate call is similar to a regular call, except that all code is executed in the context of the caller (proxy), not of the callee (implementation). Because of this, a transfer in the implementation contract’s code will transfer the proxy’s balance, and any reads or writes to the contract storage will read or write from the proxy’s storage.

This approach is better because the users only interact with the proxy contract and we can change the implementation contract while keeping the same proxy contract.

This seems better than the previous approach, but if we need to make any changes to the implementation contract methods, we would need to update the proxy contract's methods too (as the proxy contract has interface methods). Hence, users will need to change the proxy address.

To solve this problem, we can use a fallback function in the proxy contract. The fallback function will execute on any request, redirecting the request to the implementation and returning the resulting value (using opcodes). This is similar to the previous approach, but here the proxy contract doesn’t have interface methods, only a fallback function, so there is no need to change the proxy address if contract methods are changed.

This was a basic explanation which is enough for us to work with upgradable contracts. In case, you want to dig deep into proxy contract code and different proxy patterns, then check out this post.

How Upgradable Smart Contracts Work Under the Hood

OpenZeppelin Upgrades

As we saw above, there are a lot of things you need to manage while writing upgradeable contracts.

Fortunately, projects like OpenZeppelin have built CLI tools & libraries, which provide an easy to use, simple, robust, and opt-in upgrade mechanism for smart contracts that can be controlled by any type of governance, be it a multi-sig wallet, a simple address or a complex DAO.

Let's first build a basic upgradeable contract using the OpenZeppelin CLI tool. You can find the code for the below implementation here.

In case you get stuck somewhere or have any doubts, let us know here.

OpenZeppelin Upgrades CLI

Working with OpenZeppelin CLI requires Node.js for development. If you don't have it already, install node using whatever package manager you prefer or using the official installer.

Project Setup

Create a folder named upgradable-smart-contracts and go inside the folder.

$ mkdir upgradable-smart-contracts && cd upgradable-smart-contracts 
Throughout this tutorial, we use the $ character to indicate your terminal’s shell prompt. When following along, don’t type the $ character, or you’ll get some weird errors.

We will use a local blockchain for this tutorial. The most popular local blockchain is Ganache. To install and run it on your project, run:

$ npm install --save-dev ganache-cli && npx ganache-cli --deterministic

Now, start a new shell/terminal in the same folder run the following command to install the CLI tool:

$ npm install --save-dev @openzeppelin/cli

To manage your deployed contracts, you need to create a new CLI project. Run the following command, and provide it with a name and version number for your project when prompted:

$ npx openzeppelin init

Two things will happen during initialization. First, an `.openzeppelin` directory will be created, holding project-specific information. This directory will be managed by the CLI: you won’t need to manually edit anything. You should, however, commit some of these files to Git.

Second, the CLI will store network configuration in a file called networks.js. For convenience, it is already populated with an entry called development, with configuration matching Ganache's default.

You can see all the unlocked accounts by running the following command:

$ npx openzeppelin accounts

Writing & deploying contracts

Now, let's create a contract named TodoList in the contracts folder.

// contracts/TodoList.sol
pragma solidity ^0.6.3;

contract TodoList {
    string[] private list;

    // Emitted when the storeda new item is added to the list
    event ItemAdded(string item);

    // Adds a new item in the list
    function addItem(string memory newItem) public {
        list.push(newItem);
        emit ItemAdded(newItem);
    }

    // Gets the item from the list according to index
    function getListItem(uint256 index)
        public
        view
        returns (string memory item)
    {
        return list[index];
    }
}

Now, let's deploy this contract on the local blockchain.

$ npx openzeppelin create

As we can see our contract is deployed at 0xD833215cBcc3f914bD1C9ece3EE7BF8B14f841bb.

Let's add an item ("responding to emails") to the list array using the addItem() function by running npx openzeppelin send-tx.

Now, let's suppose we need to add a new function named getListSize() to get the size of the list. Just add a new function inside the TodoList contract.

// contracts/TodoList.sol
pragma solidity ^0.6.3;

contract TodoList {
	// ...


    // Gets the size of the list
    function getListSize() public view returns (uint256 size) {
        return list.length;
    }
}

After changing the Solidity file, we can now just upgrade the instance we had deployed earlier by running the openzeppelin upgrade command.

Done! Our TodoList instance has been upgraded to the latest version of the code while keeping its state and the same address as before. We didn’t need to create & deploy the proxy contract or link the proxy to the TodoList. All that is done under the hood!

Let’s try it out by invoking the new getListSize() function, and checking the size of the list in the new contract:

That’s it! Notice how the size of the list was preserved throughout the upgrade, as well as its address. And this process is the same regardless of whether you are working on a local blockchain, a testnet, or the main network.

Upgrading Contracts Programmatically

If you want to create and upgrade contracts from your JavaScript code instead of via the command line, you can use the OpenZeppelin Upgrades library instead of the CLI.

The CLI does not just manage contract upgrades, but also compilation, interaction, and source code verification. The Upgrades library only takes care of creating and upgrading. The library also does not keep track of the contracts you have already deployed, nor runs any initializer or storage layout validations, as the CLI does. Nevertheless, these capabilities may be added to the Upgrades library in the near future.

You can find the code for the below implementation here.

In case you get stuck somewhere or have any doubts, let us know here.

In case you have not followed the above OpenZeppelin CLI part, you need to install NodeJs & Ganache as instructed here.

Your first step will be to install the library in your project, and you will also probably want to install web3 to interact with our contracts using JavaScript, and @openzeppelin/contract-loader to load the contracts from the JSON artifacts.

$ npm install web3 @openzeppelin/upgrades @openzeppelin/contract-loader

Now, create a file index.js inside upgradable-smart-contracts folder and paste this boilerplate code.

// index.js
const Web3 = require("web3");
const {
  ZWeb3,
  Contracts,
  ProxyAdminProject
} = require("@openzeppelin/upgrades");

async function main() {
  // Set up web3 object, connected to the local development network, initialize the Upgrades library
  const web3 = new Web3("http://localhost:8545");
  ZWeb3.initialize(web3.currentProvider);
  const loader = setupLoader({ provider: web3 }).web3;
}

main();

Here we set up web3 object, connected to the local development network, initialize the Upgrades library via ZWeb3.initialize, and initialize the contract loader.

Now, add this following snippet in the main() to create a new project, to manage our upgradeable contracts.

async function main() {
  // ...
    
  //Fetch the default account
  const from = await ZWeb3.defaultAccount();

  //creating a new project, to manage our upgradeable contracts.
  const project = new ProxyAdminProject("MyProject", null, null, {
    from,
    gas: 1e6,
    gasPrice: 1e9
  });
}

Now, using this project, we can create an instance of any contract. The project will take care of deploying it in such a way it can be upgraded later.

Let's create 2 contracts, TodoList1 and its updated version TodoList2 inside upgradable-smart-contracts/contracts folder.

// contracts/TodoList1.sol
pragma solidity ^0.6.3;

contract TodoList1 {
    string[] private list;

    // Emitted when the storeda new item is added to the list
    event ItemAdded(string item);

    // Adds a new item in the list
    function addItem(string memory newItem) public {
        list.push(newItem);
        emit ItemAdded(newItem);
    }

    // Gets the item from the list according to index
    function getListItem(uint256 index)
        public
        view
        returns (string memory item)
    {
        return list[index];
    }
}

To create TodoList2, just add a new getListSize() function in the above contract.

// contracts/TodoList2.sol
pragma solidity ^0.6.3;

contract TodoList2 {
    string[] private list;

    // Emitted when the storeda new item is added to the list
    event ItemAdded(string item);

    // Adds a new item in the list
    function addItem(string memory newItem) public {
        list.push(newItem);
        emit ItemAdded(newItem);
    }

    // Gets the item from the list according to index
    function getListItem(uint256 index)
        public
        view
        returns (string memory item)
    {
        return list[index];
    }

    // Gets the size of the list
    function getListSize() public view returns (uint256 size) {
        return list.length;
    }
}

Now, we need to compile these 2 contracts using:

$ npx openzeppelin compile

This will create JSON contract artifacts in the build/contracts folder. These artifacts files contain all the information about the contracts that we would need to deploy and interact with the contracts.

Now, let's create an instance of TodoList1 using the project we created above.

async function main() {
//...
    
    
//Using this project, we can now create an instance of any contract.
  //The project will take care of deploying it in such a way it can be upgraded later.
  const TodoList1 = Contracts.getFromLocal("TodoList1");
  const instance = await project.createProxy(TodoList1);
  const address = instance.options.address;
  console.log("Proxy Contract Address 1: ", address);
}

Here we get the TodoList1 contract details from the contract artifacts we created above using Contracts.getFromLocal. Then we create & deploy a pair of proxy & implementation (TodoList1) contracts and link the proxy contract to the TodoList1 via project.createProxy method. Finally, we print out the address of our proxy contract.

Now, let's add an item to the list using addItem() method and then fetch the added item using getListItem().

async function main() {
//...
    
  // Send a transaction to add a new item in the TodoList1
  await todoList1.methods
    .addItem("go to class")
    .send({ from: from, gas: 100000, gasPrice: 1e6 });

  // Call the getListItem() function to fetch the added item from TodoList1
  var item = await todoList1.methods.getListItem(0).call();
  console.log("TodoList1: List Item 0: ", item);
}

Now, let's update our TodoList1 contract to TodoList2.

async function main() {
//...
    
    
//After deploying the contract, you can upgrade it to a new version of
  //the code using the upgradeProxy method, and providing the instance address.
  const TodoList2 = Contracts.getFromLocal("TodoList2");
  const updatedInstance = await project.upgradeProxy(address, TodoList2);
  console.log("Proxy Contract Address 2: ", updatedInstance.options.address);
}

Here we get the TodoList2 contract details from the contract artifacts. Then we update our contract via project.upgradeProxy method which takes 2 parameters, the address of the proxy contract that we deployed in the previous step, and the TodoList2 contract object. We then print out the address of the proxy contract after the update.

Now, let's add a new item to the TodoList2 and fetch the items.

async function main() {
//...
    
    
  // Send a transaction to add a new item in the TodoList2
  await todoList2.methods
    .addItem("code")
    .send({ from: from, gas: 100000, gasPrice: 1e6 });

  // Call the getListItem() function to fetch the added items from TodoList2
  var item0 = await todoList2.methods.getListItem(0).call();
  var item1 = await todoList2.methods.getListItem(1).call();
  console.log("TodoList2: List Item 0: ", item0);
  console.log("TodoList2: List Item 1: ", item1);
}

Now, let's run the index.js using node index.js.

Here we can observe 2 things:

  1. The address of the proxy contract did not change even after we updated TodoList1 to TodoList2.
  2. As we got 2 items from the TodoList2, this shows that the state was preserved throughout the update.

Hence we can say that TodoList1 instance has been upgraded to the latest version of the code (TodoList2), while keeping its state and the same address as before.

In case you get stuck somewhere or have any doubts, let us know here.

Now, as we have seen how to upgrade contracts, let's see a few limitations & workarounds that you need to know about when writing more complex contracts.

A few things to keep in mind: limitations & workarounds

When working with upgradeable contracts using OpenZeppelin Upgrades, there are a few minor caveats to keep in mind when writing your Solidity code.

It’s worth mentioning that these restrictions have their roots in how the Ethereum VM works, and apply to all projects that work with upgradeable contracts, not just OpenZeppelin Upgrades.

If your contract is not compatible for upgrades, the CLI will warn you when you try to upgrade. This feature is not there yet in the OpenZeppelin Upgrades libraries.

In order to understand the limitations & workarounds, let's take an Example contract, explore the limitations in the contract and add some workarounds to make the contract upgradable.

// contracts/Example.sol

pragma solidity ^0.6.0;

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20Capped.sol";

contract Example {
    uint256 private _cap = 1000000000000000000;
    ERC20Capped public token;

    constructor(uint8 cap) public {
        _cap = cap;
        token = new ERC20Capped(_cap);
    }
}

Limitation 1: No Constructors

Due to a requirement of the proxy-based upgradeability system, no constructors can be used in upgradeable contracts. To learn about the reasons behind this restriction, head to this post.

Workaround: Initializer

A workaround is to replace the constructor with a function, typically named initialize, where you run constructor logic.

// contracts/Example.sol
pragma solidity ^0.6.0;

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20Capped.sol";

contract Example {
    uint256 private _cap = 1000000000000000000;
    ERC20Capped public token;

    function initialize(uint8 cap) public {
        _cap = cap;
        token = new ERC20Capped(_cap);
    }
}

Now, as the constructor is called only once when the contract is initialized, we need to add a check to ensure that the initialize function gets called only once.

// contracts/Example.sol
pragma solidity ^0.6.0;

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20Capped.sol";

contract Example {
    uint256 private _cap = 1000000000000000000;
    ERC20Capped public token;
    
    bool private _initialized = false;

    function initialize(uint8 cap) public {
        require(!_initialized);
        _initialized = true;
        _cap = cap;
        token = new ERC20Capped(_cap);
    }
}

Since this will be a common thing to do when writing upgradeable contracts, OpenZeppelin Upgrades provides an Initializable base contract that has an initializer modifier that takes care of this:

// contracts/Example.sol
pragma solidity ^0.6.0;

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20Capped.sol";
import "@openzeppelin/upgrades/contracts/Initializable.sol";

contract Example is Initializable {
    uint256 private _cap = 1000000000000000000;
    ERC20Capped public token;
    
    function initialize(uint8 cap) public initializer {
        _cap = cap;
        token = new ERC20Capped(_cap);
    }
}

Another difference between a constructor and a regular function is that Solidity takes care of automatically invoking the constructors of all ancestors of a contract. When writing an initializer, you need to take special care to manually call the initializers of all parent contracts:

// contracts/Example.sol
pragma solidity ^0.6.0;

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20Capped.sol";
import "@openzeppelin/upgrades/contracts/Initializable.sol";


contract BaseExample is Initializable {
    uint256 public createdAt;
    
    function initialize() initializer public {
        createdAt = block.timestamp;
    }
    
}

contract Example is BaseExample {
    uint256 private _cap = 1000000000000000000;
    ERC20Capped public token;

    function initialize(uint8 cap) initializer public {
        _cap = cap;
        token = new ERC20Capped(_cap);
    }
}

Keep in mind that this restriction affects not only your contracts, but also the contracts you import from a library. Consider for example ERC20Capped from OpenZeppelin Contracts: the contract initializes the token’s cap in its constructor.

pragma solidity ^0.6.0;

import "./ERC20.sol";

/**
 * @dev Extension of {ERC20} that adds a cap to the supply of tokens.
 */
contract ERC20Capped is ERC20 {
    uint256 private _cap;

    /**
     * @dev Sets the value of the `cap`. This value is immutable, it can only be
     * set once during construction.
     */
    constructor (uint256 cap) public {
        require(cap > 0, "ERC20Capped: cap is 0");
        _cap = cap;
    }
    
    //...
}

This means you should not be using these contracts in your OpenZeppelin Upgrades project. Instead, make sure to use @openzeppelin/contracts-ethereum-package, which is an official fork of OpenZeppelin Contracts that has been modified to use initializers instead of constructors. Take a look at how ERC20Capped looks in @openzeppelin/contracts-ethereum-package:

pragma solidity ^0.5.0;

import "@openzeppelin/upgrades/contracts/Initializable.sol";
import "./ERC20Mintable.sol";

/**
 * @dev Extension of {ERC20Mintable} that adds a cap to the supply of tokens.
 */
contract ERC20Capped is Initializable, ERC20Mintable {
    uint256 private _cap;

    /**
     * @dev Sets the value of the `cap`. This value is immutable, it can only be
     * set once during construction.
     */
    function initialize(uint256 cap, address sender) public initializer {
        ERC20Mintable.initialize(sender);

        require(cap > 0, "ERC20Capped: cap is 0");
        _cap = cap;
    }

	//...
}

Whether using OpenZeppelin Contracts or another Ethereum Package, always make sure that the package is set up to handle upgradeable contracts.

// contracts/Example.sol
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-ethereum-package/contracts/token/ERC20/ERC20Capped.sol";
import "@openzeppelin/upgrades/contracts/Initializable.sol";


contract BaseExample is Initializable {
    uint256 public createdAt;
    
    function initialize() initializer public {
        createdAt = block.timestamp;
    }
    
}

contract Example is BaseExample {
    uint256 private _cap = 1000000000000000000;
    ERC20Capped public token;
 
    function initialize(uint8 cap) initializer public {
        _cap = cap;
        token = new ERC20Capped(_cap);
    }
}

Limitation 2: Initial Values in Field Declarations

Solidity allows defining initial values for fields when declaring them in a contract.

contract Example is BaseExample {
    uint256 private _cap = 1000000000000000000;
	
    //...
}

This is equivalent to setting these values in the constructor, and as such, will not work for upgradeable contracts.

Workaround: Initializer

Make sure that all initial values are set in an initializer function as shown below; otherwise, any upgradeable instances will not have these fields set.

//...

contract Example is BaseExample {
    uint256 private _cap;
    ERC20Capped public token;

    function initialize(uint8 cap) initializer public {
        _cap = 1000000000000000000;
        _cap = cap;
        token = new ERC20Capped(_cap);
    }
}

Note that it still is fine to set constants here, because the compiler does not reserve a storage slot for these variables, and every occurrence is replaced by the respective constant expression. So the following still works with OpenZeppelin Upgrades:

//...

contract Example is BaseExample {
    uint256 constant private _cap = 1000000000000000000;
    
    //...
}

Limitation: Creating New Instances From Your Contract Code

When creating a new instance of a contract from your contract’s code, these creations are handled directly by Solidity and not by OpenZeppelin Upgrades, which means that these contracts will not be upgradeable.

For instance, in the following example, even if Example is upgradeable (if created via openzeppelin create Example), the token contract created is not:

//...

contract Example is BaseExample {
    uint256 private _cap = 1000000000000000000;
    ERC20Capped public token;

    function initialize(uint8 cap) initializer public {
        _cap = cap;
        token = new ERC20Capped(_cap);
    }
}

Workaround: Inject a pre-deployed contract from CLI

The easiest way around this issue is to avoid creating contracts on your own altogether: instead of creating a contract in an initialize function, simply accept an instance of that contract as a parameter, and inject it after creating it from the OpenZeppelin CLI:

//...

contract Example is BaseExample {
    ERC20Capped public token;
    
    function initialize(ERC20Capped _token) initializer public {
        token = _token;
    }
}
$ TOKEN=$(npx openzeppelin create TokenContract)
$ npx oz create Example --init --args $TOKEN

Workaround: OpenZeppelin App Contract

An advanced alternative, if you need to create upgradeable contracts on the fly, is to keep an instance of your OpenZeppelin project’s App in your contracts. The App is a contract that acts as the entrypoint for your OpenZeppelin project, which has references to your logic implementations, and can create new contract instances:

// contracts/Example.sol
pragma solidity ^0.6.0;

import "@openzeppelin/upgrades/contracts/Initializable.sol";
import "@openzeppelin/upgrades/contracts/application/App.sol";

contract BaseExample is Initializable {
    //...
}

contract Example is BaseExample {
    
  App private app;

  function initialize(App _app) initializer public {
    app = _app;
  }

  function createNewToken() public returns(address) {
    return app.create("@openzeppelin/contracts-ethereum-package", "ERC20Capped");
  }
}

Potentially Unsafe Operations

When working with upgradeable smart contracts, you will always interact with the proxy contract instance, and never with the underlying logic (implementation) contract. However, nothing prevents a malicious actor from sending transactions to the logic contract directly. This does not pose a threat, since any changes to the state of the logic contracts do not affect your proxy contract instances, as the storage of the logic contracts is never used in your project.

There is, however, an exception. If the direct call to the logic contract triggers a selfdestruct operation, then the logic contract will be destroyed, and all your contract instances will end up delegating all calls to an address without any code. This would effectively break all contract instances in your project.

A similar effect can be achieved if the logic contract contains a delegatecall operation. If the contract can be made to delegatecall into a malicious contract that contains a selfdestruct, then the calling contract will be destroyed.

pragma solidity ^0.6.0;

// The Exmaple contract makes a `delegatecall` to the Malicious contract. Thus, even if the Malicious contract runs the `selfdestruct` function, it is run in the context of the Example contract, thus killing the Example contract.  

contract Example {
    function testFunc(address malicious) public {
        malicious.delegatecall(abi.encodeWithSignature("kill()"));
    }
}

contract Malicious {
    function kill() public {
        address payable addr = address(uint160(address(0x4Bf8c809c898ee52Eb7fc6e1FdbB067423326B2A)));
        selfdestruct(addr);
    }
}

As such, it is strongly recommended to avoid any usage of either selfdestruct or delegatecall in your contracts. If you need to include them, make absolutely sure they cannot be called by an attacker on an uninitialized logic contract.

Modifying Your Contracts

When writing new versions of your contracts, either due to new features or bugfixing, there is an additional restriction to observe: you cannot change the order in which the contract state variables are declared, nor their type. You can read more about the reasons behind this restriction by learning about Proxies.

Violating any of these storage layout restrictions will cause the upgraded version of the contract to have its storage values mixed up, and can lead to critical errors in your application.

This means that if you have an initial contract that looks like this:

pragma solidity ^0.6.3;

contract Example {
    string public tokenName;
    uint8 public decimals;
}

Then you cannot change the type of a variable:

pragma solidity ^0.6.3;

contract Example {
    string public tokenName;
    uint public decimals;
}

Or change the order in which they are declared:

pragma solidity ^0.6.3;

contract Example {
    uint public decimals;
    string public tokenName;
}

Or introduce a new variable before existing ones:

pragma solidity ^0.6.3;

contract Example {
	string public tokenSymbol;
    string public tokenName;
    uint public decimals;
}

Or remove an existing variable:

pragma solidity ^0.6.3;

contract Example {
    string public tokenName;
}

If you need to introduce a new variable, make sure you always do so at the end:

pragma solidity ^0.6.3;

contract Example {
    string public tokenName;
    uint public decimals;
	string public tokenSymbol;
}

Keep in mind that if you rename a variable, then it will keep the same value as before after upgrading. This may be the desired behaviour if the new variable is semantically the same as the old one:

pragma solidity ^0.6.3;

contract Example {
    string public tokenName;
    uint public decimalCount;	// starts with the value of `decimals`
}

And if you remove a variable from the end of the contract, note that the storage will not be cleared. A subsequent update that adds a new variable will cause that variable to read the leftover value from the deleted one.

pragma solidity ^0.6.3;

contract Example1 {
    string public tokenName;
    uint public decimals;
}

// Updating Example1 --> Example2

contract Example2 {
    string public tokenName;
}

// Updating Example2 --> Example3

contract Example3 {
    string public tokenName;
    uint public decimalCount;	// starts with the value of `decimals`
}

Note that you may also be inadvertently changing the storage variables of your contract by changing its parent (base) contracts. For instance, if you have the following contracts:

pragma solidity ^0.6.3;

contract BaseExample1 {
	uint256 createdAt;
}

contract BaseExample2 {
	string version;
}

contract Example is BaseExample1, BaseExample2 {}

Then modifying Example by swapping the order in which the base contracts are declared, or adding new base contracts or removing base contracts, will change how the variables are actually stored:

pragma solidity ^0.6.3;

contract BaseExample1 {
	uint256 createdAt;
}

contract BaseExample2 {
	string version;
}

//swapping the order in which the base contracts are declared
contract Example is BaseExample2, BaseExample1 {}

//Or...

//removing base contract(s)
contract Example is BaseExample1 {}

//Or...

contract BaseExample3 {} 

//adding new base contract
contract Example is BaseExample1, BaseExample2, BaseExample3 {}

You also cannot add new variables to base contracts, if the child has any variables of its own. Given the following scenario:

pragma solidity ^0.6.3;

contract BaseExample {}

contract Example is BaseExample {
	string tokenName;
}

//Now, if the BaseExample is updated to the following

contract BaseExample {
	string version;		// takes the value of `tokenName` 
}

contract Example is BaseExample {
	string tokenName;
}

Then the variable version would be assigned the slot that tokenName had in the previous version.

You also remove a variable from the base contract, if the child has any variables of its own. For example:

pragma solidity ^0.6.3;

contract BaseExample {
	uint256 createdAt;
    string version;
}

contract Example is BaseExample {
	string tokenName;
}

//Now, if the BaseExample is updated to the following

contract BaseExample {
	uint256 createdAt; 
}

contract Example is BaseExample {
	string tokenName;	//takes the value of `version`
}

Here, as we remove the version variable from the BaseExample, the memory slot for version (before update) will now be used by tokenName (after update).

A workaround for this is to declare unused variables on base contracts that you may want to extend in the future, as a means of "reserving" those slots. So, basically, keeping the number and order of the variables in the parent and child contracts same for all the updates.

pragma solidity ^0.6.3;

contract BaseExample {
	string someVar1;
    string someVar2;
    string someVar3;
    
    //...
}

Note that this trick does not involve increased gas usage.

In case you get stuck somewhere or have any doubts, let us know here.

Credits

We have copied some of the text from these amazing docs from OpenZeppelin & NuCypher.

  1. NuCypher’s Approaches to Upgradeable Contracts
  2. Upgrading Smart Contracts
  3. Writing Upgradeable Contracts

This article was first published on our open-source platform, SimpleAsWater.com. If you are interested in IPFS, Libp2p, Ethereum, Zero-knowledge Proofs, Defi, CryptoEconomics, IPLD, Multi formats, and other Web 3.0 projects, concepts and interactive tutorials, then be sure to check out SimpleAsWater.