Create an upgradable smart contract by using OpenZeppelin. There are several common patterns for upgradable contract. You can get more from this article.
The State of Smart Contract Upgrades
This is one of these pattern's impelementation Universal upgradeable proxies (UUPS proxies)
pattern and tutorial is below:
UUPS Proxies: Tutorial (Solidity + JavaScript)
We'll create our first smart contract named Box
with 2 very basic functions store()
and retrieve()
for write and read the state variable. Then create an second smart contract BoxV2
and add another function increment()
to add specified value to state variable.
The contract doesn't do much but just shows how to use OpenZeppelin upgradeble plugin to make contract can be upgraded.
- Hardhat + Ethers.js
- Solidity v0.8.11
- OpenZeppelin
-
Creating a new Hardhat project
# Create project directory mkdir uups-proxies && cd $_ # Choose basic sample project setting npx hardhat # Install neccessary OpenZeppelin libraries npm i -D @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades # dotenv for reading environment variables npm i -D dotenv
-
Create an account on Rinkeby testnet and obtain private key
Use MetaMask wallet client to create an account on Rinkeby testnet and export the private key. Remember to get suffecient ETH from faucet first. You can reqest test ETH from the following link:
-
Create a .env file
RINKEBY_RPC_URL=https://rinkeby.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161 PRIVATE_KEY=[REPLACE WITH YOUR PRIVATE KEY]
-
Config Hardhat configuration file
// hardhat.config.js require("@nomiclabs/hardhat-ethers"); require("@openzeppelin/hardhat-upgrades"); // Read Rinkeby RPC url and private key from environment variable require("dotenv").config(); const RINKEBY_RPC_URL = process.env.RINKEBY_RPC_URL; const PRIVATE_KEY = process.env.PRIVATE_KEY; module.exports = { solidity: "0.8.11", networks: { rinkeby: { url: RINKEBY_RPC_URL, accounts: [PRIVATE_KEY], gas: 2100000, }, }, };
Create a very simple smart contract with 2 basic functions store()
and retrieve()
. store()
can update the state variable value
. retrieve()
just read the value.
We import three OpenZeppelin library to impelement upgradable contract.Because this is upgradable contract. Do not in initialize the contract state in the constructor. Instead we have an initializer function decorated with the modifier initializer. This is UUPS proxies pattern requirements.
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract Box is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 private value;
// Because this is upgradable contract
// Do not in initialize the contract state in the constructor
// Instead we have an initializer function decorated with the modifier initializer
function initialize() initializer public {
__Ownable_init();
__UUPSUpgradeable_init();
}
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() initializer {}
function _authorizeUpgrade(address) internal override onlyOwner {}
event ValueChanged(uint256 newValue);
// Stores a new value in the contract
function store(uint256 newValue) public {
value = newValue;
emit ValueChanged(newValue);
}
// Reads the last stored value
function retrieve() public view returns (uint256) {
return value;
}
}
Test before we deploy to live testnet. We first use deployProxy()
function to deploy first version contract.
// test/Box.test.ts
const { ethers, upgrades } = require("hardhat");
let contract_proxy_address;
describe("Test Box deploy", function () {
it("Deploying Box v1", async function () {
const Box = await ethers.getContractFactory("Box");
// The { kind: "uups" } option is the key point and requirement for UUPS implementation here.
const box = await upgrades.deployProxy(Box, { kind: "uups" });
contract_proxy_address = box.address;
});
});
Run the test file with Hardhat cli
npx hardhat test
Let's duplicate origin Box.sol
and rename to BoxV2.sol
. And just add an new function increment()
. Get rid off the code constructor() initializer {}
. Because upgrade contract doesn't need the constructor.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract BoxV2 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 private value;
// Use initialize function instead of constructor to meet special requirements of UUPS proxiex pattern
function initialize() initializer public {
__Ownable_init();
__UUPSUpgradeable_init();
}
// Make sure that the contract can be upgraded by contract owner
function _authorizeUpgrade(address) internal override onlyOwner {}
// Emitted when the stored value changes
event ValueChanged(uint256 newValue);
// Stores a new value in the contract
function store(uint256 newValue) public {
value = newValue;
emit ValueChanged(newValue);
}
// Reads the last stored value
function retrieve() public view returns (uint256) {
return value;
}
// New function for V2
function increment() public {
value = value + 1;
emit ValueChanged(value);
}
}
Edit test script and add another test case. Upgrade contract with upgradeProxy()
and V1 contract's address.
// test/Box.test.ts
const { ethers, upgrades } = require("hardhat");
let contract_proxy_address;
describe("Test Box v1 deploy", function () {
it("Deploying Box v1", async function () {
const Box = await ethers.getContractFactory("Box");
// The { kind: "uups" } option is the key point and requirement for UUPS implementation here.
const box = await upgrades.deployProxy(Box, { kind: "uups" });
contract_proxy_address = box.address;
});
it("Upgrading to v2", async function () {
const BoxV2 = await ethers.getContractFactory("BoxV2");
await upgrades.upgradeProxy(contract_proxy_address, BoxV2);
});
});
Run the test file with Hardhat cli
npx hardhat test
Create a deploy script scripts/deploy.js
for deploy V1 contract. Notice the {kind: "uups"}
option in the code. It's key point for UUPS proxies pattern.
// scripts/deploy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const Box = await ethers.getContractFactory("Box");
// The { kind: "uups" } option is the key point and requirement for UUPS implementation here.
const box = await upgrades.deployProxy(Box, { kind: "uups" });
console.log("Box V1 deployed to:", box.address);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Run the script to deploy contract to Rinkeby testnet.
npx hardhat run scripts/deploy.js --network rinkeby
Obtain the contract address after run the script. We'll use this later.
0x9b5B064626074c807c29EFcE716d49600317688D
Check them on Etherscan after deploy successfully.
https://rinkeby.etherscan.io/address/0x9b5B064626074c807c29EFcE716d49600317688D
Use Hardhat console to interact with deployed contract and verify contract works as well.
npx hardhat console --network rinkeby
Enter following commands in Hardhat console to interact with contract. We'll get contract instance first, then read the state variable to see the value. Next we use store()
to write new value, and read value again to see if value changed.
const Box = await ethers.getContractFactory("Box")
const box = Box.attach("0x9b5B064626074c807c29EFcE716d49600317688D")
(await box.retrieve()).toString();
await box.store(10)
(await box.retrieve()).toString();
Transaction details on Etherscan:
https://rinkeby.etherscan.io/tx/0xb3a4c186f566b4738f0f0a2b84cbb7d3f03dd348495e9f6a25800de85afe3e80
Quit the console mode with hit CTRL+C
twice.
Remember to fill in the BOX_ADDRESS
with the address of the Box contract. Then use upgradeProxy()
to do a contract upgrading. {kind: "uups"}
option is not neccessary for upgrade.
// scripts/upgrade.js
const { ethers, upgrades } = require("hardhat");
// Fill in the BOX_ADDRESS variable below with the address of the Box contract
const BOX_ADDRESS = "0x9b5B064626074c807c29EFcE716d49600317688D";
async function main() {
const BoxV2 = await ethers.getContractFactory("BoxV2");
const box = await upgrades.upgradeProxy(BOX_ADDRESS, BoxV2);
console.log("Box upgraded");
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Run the script to upgrade contract to V2
npx hardhat run scripts/upgrade.js --network rinkeby
We will interact with upgraded contract and contract address is still same as contract v1 so that we can proove contract was upgraded.
npx hardhat console --network rinkeby
Enter following commands in Hardhat console to interact with contract. Upgraded contract address still same as V1.
const BoxV2 = await ethers.getContractFactory("BoxV2")
const boxV2 = BoxV2.attach("0x9b5B064626074c807c29EFcE716d49600317688D")
(await boxV2.retrieve()).toString()
await boxV2.increment()
(await boxV2.retrieve()).toString()
Transaction details on Etherscan:
https://rinkeby.etherscan.io/tx/0x3b81f913af374904bd525582d708cb8d46d3dd500f65dc3a45ffe43d9a7c42fd