Creating Upgradable Solidity Contract With Hardhat
Jamiebones
Posted on April 5, 2022
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
- Project
- How Upgradable Contract Works
- Verify Upgradable Contract
- Upgrading a smart contract
- Things to know when working with Upgradable Contracts
Project Dependencies
npm init --y
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"
}
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
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,
},
};
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;
}
}
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();
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
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
. This contract is the
TransparentUpgradeableProxyProxy
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.
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;
}
}
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();
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
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. OpenaeppelinInitializable
can be used to ensure a function is called once. (initializer
)
function initialize(uint256 _val ) external initializer {
val = _val;
}
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;
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;
- variable initialization : only state variable declared as
const
andimmutable
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)));
}
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...
Posted on April 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.