Building an Upgradable Smart Contract on Rootstock

Mercy MakindeMercy Makinde
12 min read

When you see this topic, what do you think? "Smart contracts are immutable." Yes, smart contracts can be immutable, but they can also be upgradable.

An upgradeable smart contract is a type of smart contract that allows for modifications or improvements after it has been deployed on the blockchain. These smart contracts are designed to be flexible, enabling developers to fix bugs, add new features, or adapt to changing requirements without needing to deploy a completely new contract.

In this article, you will learn what upgradable smart contracts are, how they work and how to build one on Rootstock and verify it on Rootstock testnet Explorer.

What is an Upgradable Smart Contract?

Imagine you own a bakery in a prime location that specialises in selling bread. You hire a baker who is excellent at baking bread. Business is going well, and you're making a good profit. Suddenly, you notice your customer numbers are decreasing. One day, you discover the reason: another store in a different location offers a variety of breads, cakes, and cookies. So, you hire another baker who can make these varieties, and suddenly, you start making more profit than before, and you also save people from travelling long distances.

In this case, you have "UPGRADED YOUR BUSINESS," right? This is similar to how an upgradeable smart contract works. An upgradeable contract is a smart contract setup that enables developers to update the code logic (e.g., fix bugs or add new features) without altering the contract address or compromising user data.

How Does an Upgradable Smart Contract Work

An upgradable smart contract is made of a “proxy pattern”, which contains two contracts:

  1. Proxy Contract: This is the initial contract, which is deployed first and with which the user interacts. This contract contains the contract storage and balance. It is also responsible for forwarding transactions to the implementation contract, which includes the pure logic. In a nutshell, see this contract as the waiter who takes your order to the chef.

  2. Implementation/Execution Contract(Actual Contract): This is the contract that contains logic for executing functions or processing information. That is the chef who cooks your order.

Now that you know what upgradable smart contracts are and how they work, let's learn how to build one.

Prerequisites

Before you begin, make sure you have the following:

  • Node.js

  • Node Package Manager(NPM)

  • Basic knowledge of smart contracts and Solidity

  • Code Editor(preferrably VSCode)

  • A MetaMask wallet configured with the Rootstock testnet:
    How to add the Rootstock testnet to MetaMask:

    1. Open MetaMask and go to Settings > Networks

    2. Click “Add a custom network

    3. Fill in the following details:
    Network Name: Rootstock Testnet

    RPC URL: https://public-node.testnet.rsk.co

    Chain ID: 31

    Currency Symbol: tRBTC

    4. Click Save

  • Some Rootstock testnet faucet

Tools Needed

  1. Hardhat

  2. Hardhat upgrade(openzeppelin) plugin

  3. Ethers

  4. Dotenv

Setting Up Project Environment

  1. Create a new project folder using this command:

       mkdir counter
       cd counter
    
  2. Initialise npm in your project using this command:

     npm init -y
    
  3. Install Hardhat using this command:

     npm install -- save-dev hardhat
    
  4. Initialise Hardhat in the project using this command:

     npx hardhat init
    
  5. After you have initialised hardhat, select Create a JavaScript project using the down-arrow key and accept other prompts:

  6. Install an upgradeable variant of OpenZeppelin Contracts using this command:

     npm install @openzeppelin/contracts-upgradeable @openzeppelin/contracts // This is used to write an upgradeable contract
    
  7. Install Hardhat Upgrade plugin using this command:

     npm install --save-dev @openzeppelin/hardhat-upgrades //This is used to deploy the upgradable contract
    
  8. Install Ethers and Dotenv using this command:

     npm install --save-dev @nomicfoundation/hardhat-ethers ethers dotenv
    

Configuration

In this section, you will be adding your private key and Rootstock testnet Node URL to the hardhat.config.js by following these steps:

Adding your Private Key

  1. Go to your Metamask Wallet

  2. Click on Account

  3. Click on the three dots (⋮) beside your account balance.

  4. Click on Account Details and Select Details

  5. Scroll down and Click on Show Private Key

  6. Copy the “Private Key” and save it in your .env file using this variable:

     PRIVATE_KEY=<your private key>
    

Modify the Hardhat Config File

Inside the hardhat.config.js , add the following code to it:

require("@nomicfoundation/hardhat-toolbox");
require('@openzeppelin/hardhat-upgrades');
require('dotenv').config();

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.8.28",
  networks: {
    rskTestnet: {
      url: 'https://public-node.testnet.rsk.co/', //Rootstock testnet Node URL
      accounts: [process.env.PRIVATE_KEY], //your private key
      chainId: 31, //Rootstock testnet ChainID
    },
  },
};

Creating The Smart Contract

For this tutorial, we will be building a Counter Upgradable smart counter

  1. Under the contracts > folder, you can delete the Lock.sol or modify it to Counter.sol

  2. Inside the Counter.sol, add the following code to it:

     // SPDX-License-Identifier: MIT
     pragma solidity ^0.8.28;
    
     import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
     import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
    
     contract Counter is Initializable, OwnableUpgradeable {
         uint256 public count;
    
         function initialize(uint256 _count) public initializer {
             __Ownable_init(msg.sender);
             count = _count;
         }
    
         function increment() public {
             count += 1;
         }
    
         uint256[50] private __gap; 
     }
    

    In upgradeable contracts using the proxy pattern, constructors don't work as expected. They execute when the implementation contract is deployed, not when the proxy is set up. This is why Openzeppelin introduces an initialize() function marked with the initializer modifier, which sets up the initial state and calls __Ownable_init() to configure ownership.

    Furthermore, the contract inherits from Initializable and OwnableUpgradeable to ensure compatibility with the upgradeable proxy pattern and to include access control.

    __gap It is critical for upgradeable contracts. It reserves empty storage slots in the contract to allow you to add new state variables in future versions without breaking the storage layout.

Common Pitfalls 💡

  1. Keep storage layout consistent: never change variable order or types.

  2. Don’t forget to include __gap for upgrade-safe design.

  3. Avoid the use of a constructor

Testing the Counter Smart Contract

It is a good practice to always test your contract to see if it is working as expected. To do this:

  1. Create a new folder(test) under your project

  2. Create a new file under it and name it counter.js

  3. Add the following code to it:

     const { expect } = require('chai');
     const { ethers, upgrades } = require('hardhat');
    
     describe('Counter V1', function () {
       let proxy, Counter;
    
       before(async () => {
         Counter = await ethers.getContractFactory('Counter');
         proxy = await upgrades.deployProxy(Counter, [0], { initializer: 'initialize' });
         await proxy.waitForDeployment();
       });
    
       it('should initialize with count = 0', async () => {
         expect(await proxy.count()).to.equal(0);
       });
    
       it('should increment count', async () => {
         await proxy.increment();
         expect(await proxy.count()).to.equal(1);
       });
    
       it('should return the current count', async () => {
         expect(await proxy.count()).to.equal(1);
       });
     });
    

    This means:

    • Deploy an upgradeable proxy for Counter by initialising it with a count of 0, then wait for deployment to finish.

    • Check that the counter starts at 0.

    • Calls the increment function and checks if the count increases to 1

    • Return the current value of count and check if the current value is 1

  4. Test the contract using this command:

     npx hardhat test
    

    You get a response like this that indicates your contract works as expected:

Deploying the Smart contract

  1. In your project directory, create a folder and name it “scripts”. This is where you will be writing your deployment script for each version

  2. Create a new deployment file and name it deploy.js and add the following code to it:

     const { ethers, upgrades } = require('hardhat');
    
     async function main() {
       const Counter = await ethers.getContractFactory('Counter');
       const proxy = await upgrades.deployProxy(Counter, [0], { initializer: 'initialize' });
    
       // Wait for the deployment to be mined
       await proxy.waitForDeployment();
       console.log('Proxy deployed to:', await proxy.getAddress());
     }
    
     main()
       .then(() => process.exit(0))
       .catch((error) => {
         console.error(error);
         process.exit(1);
       });
    
  3. Deploy the contract using this command:

     npx hardhat run scripts/deploy.js --network rskTestnet
    
  4. After a successful deployment, you will get your contract:

     Compiled 1 Solidity file successfully (evm target: paris).
     Proxy deployed to: 0x5DAC43C0268DeE8820cDa0590C8B5A038e011E56
    

    Note: This proxy address is what you will use to deploy subsequent versions.

Upgrading the Counter contract

It is important to note that only the account that deploys the contract can upgrade the contract.

For this upgraded version, you will add the decrement function to it to the initial Counter contract.

  1. Under contracts folder, create another file and name it CounterV2.sol

  2. After that, add the following code to it:

     // SPDX-License-Identifier: MIT
     pragma solidity ^0.8.28;
    
     import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
     import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
    
     contract CounterV2 is Initializable, OwnableUpgradeable {
        uint256 public count;
        function initialize(uint256 _count) public initializer {
            __Ownable_init(msg.sender);
            count = _count;
        }
        function increment() public {
            count += 1;
        }
        function decrement() public {
            require(count > 0, "Counter: underflow");
            count -= 1;
        }
        uint256[50] private __gap; // For future storage upgrades
     }
    

Testing the Second Counter Smart Contract

For this upgraded version, you will be testing for the following:

  • Checks that only the contract owner can upgrade the proxy. If someone else tries, the transaction should be reverted.

  • Deploys a fresh proxy instance of CounterV2 initialized with a count of 0

  • Verifies the counter starts at 0.

  • Checks that it can increase the count.

  • Checks that it can decrease the count.

  • Get the current value of the count

To do this, follow the steps here:

  1. Under test folder, create another file and name it counterV2.js

  2. Add the following code to the file:

     const { ethers, upgrades } = require('hardhat');
     const fs = require('fs');
     const path = require('path');
    
     async function main() {
       // Read proxy address from OpenZeppelin file
       const networkId = 31; // RSK Testnet
       const filePath = path.join(__dirname, '..', '.openzeppelin', `unknown-${networkId}.json`);
    
       let proxyAddress;
       try {
         const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
         proxyAddress = data.proxies[0].address;
         console.log(`Found proxy address: ${proxyAddress}`);
       } catch (error) {
         console.error('Error reading proxy address:', error);
         process.exit(1);
       }
    
       // Get the contract factories
       const Counter = await ethers.getContractFactory('Counter');
       const CounterV2 = await ethers.getContractFactory('CounterV2');
    
       try {
         console.log('Upgrading Counter...');
         await upgrades.upgradeProxy(proxyAddress, CounterV2);
         console.log('Counter upgraded successfully');
       } catch (error) {
         if (error.message.includes('is not registered')) {
           console.log('Proxy not registered. Attempting to import and upgrade...');
    
           // Force import the proxy
           await upgrades.forceImport(proxyAddress, Counter);
           console.log('Proxy imported successfully');
    
           // Now try upgrading again
           await upgrades.upgradeProxy(proxyAddress, CounterV2);
           console.log('Counter upgraded successfully after import');
         } else {
           // If it's a different error, rethrow it
           throw error;
         }
       }
     }
    
     main()
       .then(() => process.exit(0))
       .catch((error) => {
         console.error(error);
         process.exit(1);
       });
    
  3. Test the contract using the following:

     npx hardhat test test/counterV2.js
    

You will also get responses like this that indicate your upgradable contract works as expected:

Deploying The Upgraded Version

  1. Under scripts folder, create another deployment script and name it upgrade_deploy.js

  2. Add the following code to upgrade_deploy.js:

     // scripts/upgrade_box.js
     const { ethers, upgrades } = require('hardhat');
    
     async function main () {
       const CounterV2 = await ethers.getContractFactory('CounterV2');
       console.log('Upgrading Counter...');
       await upgrades.upgradeProxy('<yourproxyaddress>', CounterV2);
       console.log('Counter upgraded');
     }
     main();
    

    In this script, you will see await upgrades.upgradeProxy('ProxyAddress', CounterV2). What this line is saying is upgrade the previous version using the proxy address (the contract address from the previous version of Counter that was deployed earlier) to this latest version(CounterV2).

  3. Deploy the upgrade contract using this command:

     npx hardhat run scripts/upgrade_deploy.js --network rskTestnet
    

  4. After a successful deployment, you will get a response:

     Compiled 1 Solidity file successfully (evm target: paris).
     Upgrading Counter...
     Counter upgraded
    

    Meaning your contract has been upgraded to the latest version - CounterV2.

    Tips 💡

    Whenever you interact with an upgradable smart contract, the contract address stays the same. You just need to integrate the latest functions into your frontend codebase and reference the latest version's ABI code.

Verifying The Proxy and Implementation Contracts on Rootstock Explorer

By default, your proxy address is verified on the Rootstock testnet explorer. To verified:

  1. Go to Rootstock testnet explorer

  2. Copy your proxy address and paste it in the search field

  3. Under the contract tab, you will notice a score mark (✔️) like this:

    This indicates your contract has been verified.

Getting Rootstock Testnet Explorer API Key

  1. Sign up on Rootstock testnet explorer either with your wallet address or email

  2. Click on “Add API Key

  3. Enter your project(Counter) name

  4. Copy the generated API key and store it in your .env.

Adding Etherscan to Your hardhat.config.js

  1. Install Hardhat Verify library using this command:

     npm install --save-dev @nomicfoundation/hardhat-verify
    
  2. Import the library into the hardhat.config file:

     require("@nomicfoundation/hardhat-verify");
    
  3. Add the following to the file:

     etherscan: {
        apiKey: {
          'rskTestnet': process.env.rskTestnet_EXPLORER_API_KEY 
        },
        customChains: [
          {
            network: "rskTestnet",
            chainId: 31,
            urls: {
              apiURL: "https://rootstock-testnet.blockscout.com/api",
              browserURL: "https://rootstock-testnet.blockscout.com"
            }
          }
        ]
      }
    

Verifying the implementation contracts

To find the address of the implementation contract:

  1. open the .openzeppelin/unknown-31.json file.

  2. Inside, look for the impls object — your implementation contract address will be listed there.

  3. Run the following command together with the implementation contract address:

     npx hardhat verify --network rskTestnet <your implementation contract address>
    

Upon a successful verification, you will get a response like this:

💡 Don’t forget to verify the upgrade version.

Limitations of Upgradable Smart Contracts

Upgradable smart contracts are useful, but they have some limitations:

  1. Upgradable smart contracts can only be used on the Ethereum Virtual Machine (EVM). This means you can't create them on other blockchains that aren't compatible with EVM.

  2. You can't deploy an upgradable smart contract without an initialiser. If it's not included in the deployment script, you won't be able to deploy it.

  3. You can't change the storage layout of the contract. If you've already declared a state variable in your proxy contract, you cannot remove it, change its type, or declare another variable before it. For example, if you want to declare another variable in CounterV2, you must declare it after the count variable like this:

    
     contract CounterV2 {
         uint256 public count;
    
         //new variable 
         string private _name;
     }
    

    Otherwise, you will get an error message.

Conclusion

Upgradeable smart contracts are a great way to update decentralised applications without needing to deploy a new contract for every change. While they are useful, they have some limitations, such as not being compatible with non-EVM blockchains and not allowing changes to the storage layout or deployment without an initialiser.

Blockchains like Rootstock are highly compatible with EVM, so you can easily create an upgradeable smart contract as shown in this tutorial.

You can access the tutorial repo here

Next Steps

  1. Start building on Rootstock by checking out the documentation

  2. Join the Rootstock Developer Channel on Discord.

1
Subscribe to my newsletter

Read articles from Mercy Makinde directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mercy Makinde
Mercy Makinde

I am a front-end developer and a Guidance Counsellor. I'm enthusiastic about blockchain and its usage for the betterment of humanity. I'm teachable and enjoy reading/studying.