How to create an upgradeable Smart Contract using UUPS on Rootstock
Hello fellow developer!
In this article, we’ll learn how to create, deploy and upgrade an UUPS Smart Contract in a nutshell, using Hardhat, Open Zeppelin libraries and the Rootstock network. More precisely, we will create a stablecoin pricefeeder for any DApp.
But first, just a little bit of theory.
Architecture Patterns
There are many architecture patterns for an upgradeable contract (e.g: Diamond, Transparent, etc) but right now, UUPS (or Universal Upgradeable Proxy Standard) is the Open Zeppelin recommended pattern to create new upgradeable smart contracts. Of course, there are different use cases which may require other patterns, but UUPS is aiming to be the standard (thus the Universal keyword in its name).
UUPS Pattern
In UUPS we split the contract into 2 contracts: Proxy + Implementation.
The key difference in UUPS pattern is that the contract state is stored in the Proxy contract, so with each upgrade we don’t have to think about a state migration. The proxy contract will delegate all calls to the implementation using the delegatecall function inside the fallback function, as you can see in the image below. Using delegatecall implies that the logic contract will use the Proxy contract context. Another way to think about it is that the Proxy contract will import the implementation contract (it’s logic) to use it with its own state.
Another important thing is that the upgrade is triggered via the Proxy but actually the Implementation contract is the one who is going to upgrade to the new version, since the implementation has the logic to find the memory slot where its address is stored inside the Proxy.
All right, enough theory. Let’s start making a stablecoin price feeder using the UUPS pattern.
UUPS Stablecoin price feeder
First open a terminal and run this command to create the project’s folder and move into it:
mkdir my-uups-contract && cd my-uups-contract
Now lets install hardhat along with other required tools:
npm install hardhat @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades @nomiclabs/hardhat-etherscan dotenv -d
Then run this command to initialize the hardhat project (if you don’t know what to choose, just pick javascript and press Enter to all the options)
npx hardhat
You should have a folder structure like this:
Now create a .env file in the project’s root folder with the following content:
RSK_NODE=https://public-node.testnet.rsk.co
PRIVATE_KEY=<private key of an address that you own>
PROXY_ADDRESS=<deployed proxy address>
Note: In this case, we will use a Metamask wallet account to perform the transactions. So, you will need to create a new wallet, add the Rootstock testnet network and get the private key of one of your addresses. You are going to need RBTC for the deployments and upgrades so go to a Rootstock faucet to get some. All of this shouldn’t take more than 5 mins.
Warning: This is testnet so its ok, but always be careful about where you put your private key, since it corresponds to an address and the address/private key pair can be the same across multiple blockchains which means if someone has your private key, all your tokens may be stolen.
Don’t worry about the proxy variable yet. We are going to fulfill it later.
Now let’s configure the hardhat.config.js file with the env variables and the Rootstock network. Replace what’s inside the file with this content:
require("@nomiclabs/hardhat-ethers")
require("@openzeppelin/hardhat-upgrades")
require("@nomiclabs/hardhat-etherscan")
require("dotenv").config()
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.17",
networks: {
rsk: {
url: process.env.RSK_NODE,
accounts: [process.env.PRIVATE_KEY],
}
}
}
Now go to the contracts folder, create a file named StablecoinPriceFeeder.sol and add this content:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract StablecoinPriceFeeder is Initializable, OwnableUpgradeable, UUPSUpgradeable {
// V1: initialize function with initializer modifier. It behaves as the constructor. Executes by default when deploying.
// Later versions: initializeVxxx function with reinitializer(versionNumber) modifier. Will need to be called manually.
function initialize() public initializer {
__Ownable_init(); // Only required in V1
__UUPSUpgradeable_init(); // Required always
}
// Used when upgrading to a new implementation, only owner(s) will be able to upgrade
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {
}
// Get stablecoin price. This logic can be overriden if needed (e.g.: to use a medianizer, which may require more complex logic)
// Add "virtual override" keywords to mantain an upgradeable logic while keeping the same signature (function name + same params)
function getPrice() public view virtual returns (uint256) {
// logic to retrieve price
return 999999999999999999;
}
// Get current implementation version (only 255 upgrades are possible). Don't forget the virtual + override
function version() public pure virtual returns(uint8) {
return 1;
}
}
contract StablecoinPriceFeederV2 is StablecoinPriceFeeder {
// Starting from V2, due to inheritancy any new state variables will be stored AFTER variables declared in previous version.
uint256 public testVariableV2;
// Initializer for V2 implementation
// reinitializer(versionNumber) modifier with the new version number is required for each new version.
function initializeV2(uint256 _testVariableV2) reinitializer(2) public onlyProxy onlyOwner {
__UUPSUpgradeable_init();
testVariableV2 = _testVariableV2;
}
// Still uses getPrice() logic from V1
// Update version output
function version() public pure virtual override returns(uint8) {
return 2;
}
}
contract StablecoinPriceFeederV3 is StablecoinPriceFeederV2 {
// Remember that theres a memory space reserved for testVariable2 here.
// Initializer for V3
function initializeV3() reinitializer(3) public onlyProxy onlyOwner {
__UUPSUpgradeable_init();
}
// Overwrite price retrieval logic
function getPrice() public view virtual override returns (uint256) {
// new logic
return 1;
}
// Update Version output
function version() public pure virtual override returns(uint8) {
return 3;
}
}
To get a better understanding of each codeline, read the written comments. Note that each new version actually inherits the previous one.
But the summary is that we are importing the contracts Initializable, OwnableUpgradeable, UUPSUpgradeable from OpenZeppelin libraries, and the required functions for the UUPS pattern to work are initialize() function (also with __Ownable_init() and __UUPSUpgradeable_init() functions declared inside it) and the _authorizeUpgrade function which needs to be declared as an override to authorize the upgrade to a new version. We also declared V2 and V3 contracts in the same file
In version() function, don’t forget to update the number for each version. Otherwise your upgraded contract will output an older version number (despite having the new version functionality). Since all state variables you define will be stored in the Proxy, you can also set a version variable and update it by one with each upgrade inside the initialize function which acts as a constructor (executes only one time). Reinitializer version number and version() function output number should be the same for consistency.
Now let’s create the deploy.js and the upgradeV2.js scripts into the scripts folder:
Deploy script:
const { ethers, upgrades } = require("hardhat")
async function main () {
console.log("Deploying StablecoinPriceFeeder...")
const StablecoinPriceFeeder = await ethers.getContractFactory("StablecoinPriceFeeder")
const stablecoinPriceFeeder = await upgrades.deployProxy(StablecoinPriceFeeder, [], { initializer: "initialize" })
await stablecoinPriceFeeder.deployed()
console.log("StablecoinPriceFeeder (proxy) deployed to:", stablecoinPriceFeeder.address)
}
main()
Upgrade script:
const { ethers, upgrades } = require("hardhat")
const PROXY = process.env.PROXY_ADDRESS
if (!PROXY) {
throw new Error("You must specify the pricefeeder proxy address in .env file")
}
async function main () {
console.log("Upgrading StablecoinPriceFeeder to V2...")
const StablecoinPriceFeederV2 = await ethers.getContractFactory("StablecoinPriceFeederV2")
const stablecoinPriceFeederV2 = await upgrades.upgradeProxy(PROXY, StablecoinPriceFeederV2)
console.log("StablecoinPriceFeeder upgraded successfully", { version: await stablecoinPriceFeederV2.version() })
}
main()
Now we have to compile the contracts. To do this run:
npx hardhat clean && npx hardhat compile
Deploy
To deploy the contract run:
npx hardhat run scripts/deploy.js --network rsk >> deployResult.log
This will make your address create and send two transactions to the Rootstock node, one to deploy the Implementation contract, and the other to deploy the Proxy contract.
If everything went well, you should see in the deployResult.log file that the StablecoinPriceFeeder deployed successfully and a Proxy address logged which confirms it.
Note: In case you want to verify the contract, you have to upload the contract’s code to the Rootstock Explorer. Search for your proxy address and go to the code tab to complete the information and make the verification.
Upgrade
To upgrade to V2, take the proxy address that you found in the deploy log, add it to your .env file and run the following command:
npx hardhat run /scripts/upgradeV2.js --network rsk >> upgradeV2ResultRSK.log
Again, you should see in the upgradeV2ResultRSK.log file the upgrade result and the new version number logged. This means you replaced the implementation contract and upgraded to V2!
And that’s it! After you define and implement your specific logic to retrieve a price (e.g: calling other contracts/perform some math) you have a functional pricefeeder running in Rootstock with the UUPS pattern which is recommended by Open Zeppelin.
Extra tips
In case you need to scale, you can deploy multiple proxies for a determined contract version.
By default, in Hardhat both the logic and the proxy contracts are deployed together for V1. To deploy multiple proxies using the same logic, rerun the deploy script specifying the same contract version.
Example:
To deploy multiple V2 proxies using the same logic contract (V2), first upgrade from V1 to V2 using the upgrade script, specifying the V2 contract logic. Then, using the deploy script specify the V2 logic contract. If you run it 5 times, then 5 new proxies will be deployed for the same logic, apart from the first one deployed during the upgrade.
In summary: you have 6 proxies using the same logic (which was deployed with the upgrade to V2)
To upgrade all proxies to a new version you must use the upgrade script, specifying one by one the corresponding proxy address since they are all different instances.