DEV Community

Jamiebones
Jamiebones

Posted on

Creating Upgradable Solidity Contract With Hardhat

Smart contracts are immutable meaning once deployed on the blockchain, they can no longer be edited. This is a good thing but what if the deployed contract is buggy or you need to add a specific function to an already deployed smart contract without deploying a new contract thereby losing the state of the previous deployed contract.

OpenZeppelin comes to the rescue by providing upgradables that allow smart contract to be updated without state loss. This tutorial makes use of Hardhat and OpenZeppelin upgradable contract.

Create a new npm project by opening your terminal and typing: ( this tutorial assumes the user already has Node and npm installed in the system )

Tutorial Code lives here

Project Dependencies

npm init --y
Enter fullscreen mode Exit fullscreen mode

Open the package.json file that was created when the above command was run and add the following dependencies code below to the package.json file

  "devDependencies": {
    "@nomiclabs/hardhat-ethers": "^2.0.3",
    "@nomiclabs/hardhat-etherscan": "^2.1.8",
    "@openzeppelin/hardhat-upgrades": "^1.12.0",
    "ethers": "^5.5.2",
    "hardhat": "^2.8.0"
  },
  "dependencies": {
    "dotenv": "^16.0.0"
  }
Enter fullscreen mode Exit fullscreen mode

Install the above dependencies by running npm i. This installs the dependencies into the project. @openzeppelin/hardhat-upgrades provides functionality for creating and deploying upgradable contracts. @nomiclabs/hardhat-etherscan is used for verifying the contract using Etherscan. @nomiclabs/hardhat-ethers allows hardhat to work with ether.js.

Project

Create a new hardhat project by running in the terminal:

 npx hardhat
Enter fullscreen mode Exit fullscreen mode

This presents us options to select a project template. Select the first option which is Create a sample project and this creates a sample project with boiler plate code.

Create a .env file in the project directory. This file will contain our environment variable. In this project, we will need values for the following environmental variables which are:
INFURA_API_KEY
PRI_KEY
ETHERSCAN_API_KEY
INFURA_API_KEY : our API key we get from Infura.We will need this to connect to infura
PRI_KEY: the primary key of your account in Meta mask. This is used for signing a transaction
ETHERSCAN_API_KEY : your API key from Etherscan. This will be used for verifying a contract.

Open the hardhat-config.js file and configure it by adding the code below.

require("@nomiclabs/hardhat-ethers");
require("@openzeppelin/hardhat-upgrades");
require("@nomiclabs/hardhat-etherscan");

require('dotenv').config();
module.exports = {
  solidity: "0.8.10",
  networks: {
    ropsten: {
      url: `https://ropsten.infura.io/v3/${process.env.INFURA_API_KEY}`,
      accounts: [process.env.PRI_KEY],
    },
    rinkeby: {
      url: `https://rinkeby.infura.io/v3/${process.env.INFURA_API_KEY}`,
      accounts: [process.env.PRI_KEY]
    }
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};
Enter fullscreen mode Exit fullscreen mode

Open the contract folder in the project and delete the Greeter.sol file. Create a new file called CalculatorV1.sol. This will contain the smart contract we will deploy to the rinkeby network.

Inside the file CalculatorV1.sol replace it with the following code below:

pragma solidity 0.8.10;

import "hardhat/console.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract CalculatorV1 is Initializable {
   uint public val;
   function initialize(uint256 _val ) external initializer{
        val = _val;
    }
    function add(uint a, uint b) public pure returns (uint) {
        return a + b;
    }
   function getVal() public view returns (uint) {
        return val;
    }
}
Enter fullscreen mode Exit fullscreen mode

This smart contract is a simple contract of a calculator. The contract inherits from the Initializable contract which is an Openzeppelin contract. It ensures that the initialize function is called only once. An upgradable contract does not have a constructor so the initialize function acts as a constructor and it must be called only once. The initializer modifier ensures the function is called once.

The contract has a public variable named val and three functions which are initialize, add and getVal. We want to deploy this contract to the Rinkeby network which we have set up in the hardhat-config.js file.

Create a new file inside the scripts folder and call it deploy_contract.js. This file will contain the code that will deploy our Calculator contract for us.

Inside the deploy_contract.js file add the following code:

//scripts/deploy_contract.js
const { ethers, upgrades } = require("hardhat");

async function main() {
   const CalculatorV1 = await ethers.getContractFactory("CalculatorV1");
    console.log("Deploying Calculator...");
    const calculator = await upgrades.deployProxy(CalculatorV1, [42], {
        initializer: "initialize",
    });
    await calculator.deployed();
    console.log("Calculator deployed to:", calculator.address);
}

main();
Enter fullscreen mode Exit fullscreen mode

The code above requires ethers and upgrades from hardhat. An async function is created and inside the function, the contract factory is retrieved using ethers with the name of the contract ( CalculatorV1 ). The upgrades.deployProxy is used to deploy the contract passingin the contract factory and the initialization function with its parameter passed.

Remember in the contract, we have an initialize function that sets the value of the val variable. This function is called as the contract is being deployed passing in the value 42 as the parameter to the function.

Run on the terminal the following code to deploy the contract:

 npx hardhat run --network rinkeby scripts/deploy_contract.js
Enter fullscreen mode Exit fullscreen mode

After some few second the contract is deployed with the
contract address logged to the console.

How Upgradable Contract Works

When we deployed the contract, three contracts were deployed in total. These are a Proxy contract, a Proxy Admin contract and the Implementation contract which is our CalculatorV1. When a user interacts with the contract, he is actually interacting with the Proxy contract. The Proxy contract makes a delegateCall to our CalculatorV1 contract. For example A contract named A makes a delegateCall to a contract B calling a function in contract B. The function in B is executed in the state of variable A.

For our upgradable contract, the Proxy contract calls the Implementation contract (CalculatorV1). The state change is made on the Proxy contract. The Proxy Admin contract is used for updating the address of the implementation contract
inside the Proxy contract.

Verify Upgradable Contract

When we deployed our contract, we got back the address of the Proxy contract. if we search for this address on Ether scan we are presented with a contract with name
TransparentUpgradeableProxy
. This contract is the Proxy contract and this will be responsible for calling the Implementation contract.

To verify the Implementation contract and publish the contract code we have to look into the project folder and you will see a folder named .openZeppelin. Open the folder and you will find a file named rinkeby.json. This file is so named because of the network we deployed the contract to. This file was auto generated by hardhat when we ran the deployed script. Inside this file the addressees of the Implementation contract, Proxy admin and the Proxy are kept. As the contract is updated the new addresses are added to the file. Copy the address of the
Implementation contract and proceed to the terminal for verification.
Run this code at the terminal:
npx hardhat verify --network rinkeby contractAddress

Replace contract address with the Implementation address that was copied and run the code. This verifies the contract source code. We also need to verify the Proxy admin contract. Go to Etherscan and search for the Proxy contract using its address. Click on the Contract tab then click on Code tab and click on the more options. Click on is this a proxy? and then click on verify. The Proxy contract will be verified.

Upgrading a Contract

Create a new file inside the contract folder and name it CalculatorV2.

pragma solidity 0.8.10;

import "hardhat/console.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract CalculatorV2 is Initializable {
    uint public val;

    function add(uint a, uint b) public pure returns (uint) {
        return a + b;
    }

    function multiply(uint a, uint b) public pure returns (uint) {
        return a * b;
    }
  function getVal() public view returns (uint) {
        return val;
    }

}
Enter fullscreen mode Exit fullscreen mode

We have added a new function to this version of the contract. The multiply function is added. To upgrade the deployed contract. Create a script inside the scripts folder and create a file upgrade_contract.js. Inside this file put the following code.

const { ethers, upgrades } = require("hardhat");
//the address of the deployed proxy
const PROXY = "0xaf03a6F46Eea7386F3E5481a4756efC678a624e6";

async function main() {
    const CalculatorV2 = await ethers.getContractFactory("CalculatorV2");
    console.log("Upgrading Calculator...");
    await upgrades.upgradeProxy(PROXY, CalculatorV2);
    console.log("Calculator upgraded");
}

main();
Enter fullscreen mode Exit fullscreen mode

The address of the implementation Proxy and the contract factory of the new version of the contract is passed as parameters to upgrades.upgradeProxy. Run the code by typing on the terminal :

 npx hardhat run --network rinkeby scripts/upgrade_contract.js
Enter fullscreen mode Exit fullscreen mode

This will update the address of the Implementation contract in the Proxy contract to make use of the new version deployed. Run the getVal contract to retrieve the value of the state variable val. You will notice that the value of val is still the value we initiated it to be when we deployed the first version of the contract. That is the beauty of upgradable contracts which is the preservation of variable state.

To verify the contract, we have to perform the same steps that was used to verify the first version of the contract.

Things to know when working with Upgradable Contracts

When working with Upgradable contracts the following points should be noted:

  • Constructor: An upgradable contract can not have a constructor. If you have code that must run when the contract is created. The code should be placed in an init function that will get called when the contract is deployed. Openaeppelin Initializable can be used to ensure a function is called once. (initializer)
function initialize(uint256 _val ) external initializer {
        val = _val;
}
Enter fullscreen mode Exit fullscreen mode

The initialize function will be called only once because of the initializer modifier attached to it.

  • state variables : state variables in upgradable contracts once declared cannot be removed. Assuming we have a version one contract where we define the following state variables :
uint public val;
string public name;
Enter fullscreen mode Exit fullscreen mode

When deploying version two of the contract, we must ensure that version two of the contract upgrade also contain the same variable as version one in the same order as was defined in version one. The order of the variable matters. if we want to use new state variables, they are added at the bottom.

uint public val;
string public name;
string public newVariableOne;
uint public newVariableTwo;
Enter fullscreen mode Exit fullscreen mode
  • variable initialization : only state variable declared as const and immutable can be initialize. This is because initializing a state variable will attempt to create a storage for that variable. And as we know the Implementation contract don't use its state. The Proxy contract provides the storage used by the Implementation contract.

The value of variables declared as const are placed in the application code of the contract instead of in storage. That's why only const variable can be initialize.

  • Implementation contract can not contain code that will self destruct the contract. If a contract is self destruct and removed from the blockchain, the Proxy contract will no longer know where to look to execute functions.
function kill() external {
     selfdestruct(payable(address(0)));
}
Enter fullscreen mode Exit fullscreen mode

Summary

Having a way to upgrade smart contracts could come in useful when you need to change and improve the contract code. Thanks for reading...

Top comments (0)