Building an Upgradable Smart Contract on Rootstock


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:
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.
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 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 TestnetRPC URL: https://public-node.testnet.rsk.co
Chain ID: 31
Currency Symbol: tRBTC
4. Click Save
Tools Needed
Hardhat
Hardhat upgrade(openzeppelin) plugin
Ethers
Dotenv
Setting Up Project Environment
Create a new project folder using this command:
mkdir counter cd counter
Initialise npm in your project using this command:
npm init -y
Install Hardhat using this command:
npm install -- save-dev hardhat
Initialise Hardhat in the project using this command:
npx hardhat init
After you have initialised hardhat, select
Create a JavaScript project
using the down-arrow key and accept other prompts: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
Install Hardhat Upgrade plugin using this command:
npm install --save-dev @openzeppelin/hardhat-upgrades //This is used to deploy the upgradable contract
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
Go to your Metamask Wallet
Click on Account
Click on the three dots (⋮) beside your account balance.
Click on Account Details and Select Details
Scroll down and Click on Show Private Key
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
Under the
contracts > folder
, you can delete theLock.sol
or modify it toCounter.sol
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 theinitializer
modifier, which sets up the initial state and calls__Ownable_init()
to configure ownership.Furthermore, the contract inherits from
Initializable
andOwnableUpgradeable
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 💡
Keep storage layout consistent: never change variable order or types.
Don’t forget to include
__gap
for upgrade-safe design.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:
Create a new folder(
test)
under your projectCreate a new file under it and name it
counter.js
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 1Return the current value of count and check if the current value is 1
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
In your project directory, create a folder and name it “scripts”. This is where you will be writing your deployment script for each version
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); });
Deploy the contract using this command:
npx hardhat run scripts/deploy.js --network rskTestnet
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.
Under
contracts
folder, create another file and name itCounterV2.sol
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:
Under
test
folder, create another file and name itcounterV2.js
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); });
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
Under
scripts
folder, create another deployment script and name it upgrade_deploy.jsAdd 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).Deploy the upgrade contract using this command:
npx hardhat run scripts/upgrade_deploy.js --network rskTestnet
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:
Go to Rootstock testnet explorer
Copy your proxy address and paste it in the search field
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
Sign up on Rootstock testnet explorer either with your wallet address or email
Click on “Add API Key”
Enter your project(Counter) name
Copy the generated API key and store it in your
.env.
Adding Etherscan to Your hardhat.config.js
Install Hardhat Verify library using this command:
npm install --save-dev @nomicfoundation/hardhat-verify
Import the library into the
hardhat.config
file:require("@nomicfoundation/hardhat-verify");
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:
open the
.openzeppelin/unknown-31.json
file.Inside, look for the
impls
object — your implementation contract address will be listed there.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:
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.
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.
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 thecount
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
Start building on Rootstock by checking out the documentation
Join the Rootstock Developer Channel on Discord.
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.