Transparent Upgradable Smart Contracts: A Guide with Code Explanation
Table of contents
Smart contracts have revolutionized the way transactions occur on a blockchain. They are self-executing contracts with the terms of the agreement between buyer and seller being directly written into lines of code. Once the terms are fulfilled, the contract automatically enforces the transaction without the need for intermediaries.
But what happens when you need to upgrade the contract after deployment? Unfortunately, traditional smart contracts are immutable, meaning that any updates or changes are not possible after deployment. That’s where upgradable smart contracts come in.
Upgradable smart contracts enable updates and changes without losing the data or the contract address. The contract code remains in the same address, but the implementation is upgraded behind the scenes, and the contract remains functional with its stored data.
In this article, we will walk you through creating a Transparent Upgradable Smart Contract with a code explanation. We will use Hardhat as our development environment and OpenZeppelin’s transparent proxy as our upgradable contract mechanism.
Setting up the Development Environment
Create a project folder called “Transparent Upgradable Smart Contract” in your preferred code editor. Open your editor’s command line and paste this command to install all the necessary dependencies:
yarn add --dev @nomiclabs/hardhat-ethers@npm:hardhat-deploy-ethers ethers @nomiclabs/hardhat-etherscan @nomiclabs/hardhat-waffle chai ethereum-waffle hardhat hardhat-contract-sizer hardhat-deploy hardhat-gas-reporter prettier prettier-plugin-solidity solhint solidity-coverage dotenv
After the installation is complete, create two contracts: Box.sol
and BoxV2.sol
in the contracts folder.
In Box.sol
write the smart contract below:
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;
contract Box {
uint256 internal value;
// 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;
}
// returns the current version of the contract
function version() public pure returns (uint256) {
return 1;
}
}
The contract Box
is a simple contract that stores a single value. It has three functions: store
, retrieve
, and version
.
The
store
function stores a new value in the contract and emits an eventValueChanged
with the new value as a parameter.The
retrieve
function returns the last stored value.The
version
function returns the current version of the contract, which is1
in this case.
In BoxV2.sol
write the smart contract below:
// contracts/BoxV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;
contract BoxV2 {
uint256 internal value;
// 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;
}
// Increments the stored value by 1
function increment() public {
value = value + 1;
emit ValueChanged(value);
}
// returns the current version of the contract
function version() public pure returns (uint256) {
return 2;
}
}
The contract BoxV2
stores a single uint256 value. It has three functions:
store
: This function takes a uint256 value as input and stores it in the contract. It also emits an event ValueChanged with the new value as a parameter.retrieve
: This function is a view function that returns the last stored value.increment
: This function increments the stored value by 1 and emits an event ValueChanged with the new value as a parameter.
Additionally, the contract has a function version
that returns the current version of the contract as a uint256. In this case, the version is 2. This function is marked as pure, which means it doesn’t modify the state of the contract.
To deploy the smart contract, we need to create a deploy folder that will contain two files, 01-deploy-box.js
, and 02-deploy-boxV2.js
.
In 01-deploy-box.js
write the following code:
// deploy/01-deploy-box.js
const { developmentChains, VERIFICATION_BLOCK_CONFIRMATIONS } = require("../helper-hardhat-config")
const { network } = require("hardhat")
const { verify } = require("../helper-functions")
module.exports = async ({ getNamedAccounts, deployments }) => {
const { deploy, log } = deployments
const { deployer } = await getNamedAccounts()
const waitBlockConfirmations = developmentChains.includes(network.name)
? 1
: VERIFICATION_BLOCK_CONFIRMATIONS
log("----------------------------------------------------")
const box = await deploy("Box", {
from: deployer,
args: [],
log: true,
waitConfirmations: waitBlockConfirmations,
proxy: {
proxyContract: "OpenZeppelinTransparentProxy",
viaAdminContract: {
name: "BoxProxyAdmin",
artifact: "BoxProxyAdmin",
},
},
})
// Be sure to check out the hardhat-deploy examples to use UUPS proxies!
// https://github.com/wighawag/template-ethereum-contracts
// Verify the deployment
if (!developmentChains.includes(network.name) && process.env.ETHERSCAN_API_KEY) {
log("Verifying...")
const boxAddress = (await ethers.getContract("Box_Implementation")).address;
await verify(boxAddress, [])
}
log("----------------------------------------------------")
}
module.exports.tags = ["all", "box"]
The code block includes the following:
Importing helper modules such as
developmentChains
andVERIFICATION_BLOCK_CONFIRMATIONS
from thehelper-hardhat-config
file, network from hardhat, and verify fromhelper-functions
Defining an export function that will be called by the Hardhat deployment script. The function will take two arguments,
getNamedAccounts
anddeployments
.The function uses the
deployments
object to deploy theBox
smart contract. It retrieves thedeployer
account fromgetNamedAccounts
and uses it as the deployer of the contract.The
waitBlockConfirmations
variable is set to 1 if the network is a development chain, else toVERIFICATION_BLOCK_CONFIRMATIONS
.The deploy function is called with arguments for the contract name,
Box
, from thedeployer
account, with no arguments for the constructor, and logging set to true. Additionally, it specifies thewaitBlockConfirmations
variable, and a proxy object is provided with the proxyContract and viaAdminContract properties.The deployment is then verified by calling the
verify
function, but only if the network is not a development chain, and theETHERSCAN_API_KEY
environment variable is set.Finally, the function logs a message indicating the deployment is complete.
In 02-deploy-boxV2.js
write the following code:
// deploy/02-deploy-box.js
const { developmentChains, VERIFICATION_BLOCK_CONFIRMATIONS } = require("../helper-hardhat-config")
const { network } = require("hardhat")
const { verify } = require("../helper-functions")
module.exports = async ({ getNamedAccounts, deployments }) => {
const { deploy, log } = deployments
const { deployer } = await getNamedAccounts()
const waitBlockConfirmations = developmentChains.includes(network.name)
? 1
: VERIFICATION_BLOCK_CONFIRMATIONS
log("----------------------------------------------------")
const box = await deploy("BoxV2", {
from: deployer,
args: [],
log: true,
waitConfirmations: waitBlockConfirmations,
})
// Verify the deployment
if (!developmentChains.includes(network.name) && process.env.ETHERSCAN_API_KEY) {
log("Verifying...")
await verify(box.address, [])
}
log("----------------------------------------------------")
}
module.exports.tags = ["all", "boxv2"]
In the code above we use the deployments
and getNamedAccounts
functions provided by Hardhat to get the deployment information and named accounts respectively. It also imports two helper modules: helper-hardhat-config
and helper-functions
.
The developmentChains
and VERIFICATION_BLOCK_CONFIRMATIONS
are constants that are required from the helper-hardhat-config
module.
The waitBlockConfirmations
variable is used to determine how many block confirmations to wait for before considering the deployment complete. It is set to 1 for development chains and VERIFICATION_BLOCK_CONFIRMATIONS
for other networks.
The log
function is used to print messages to the console, indicating the start and end of the deployment process.
After deploying the BoxV2
contract, the code checks if the network is a development network, and if not, it verifies the contract deployment using the verify
function from the helper-functions
module. The verify
the function is used to ensure that the contract has been deployed correctly and matches the source code.
Finally, the module.exports.tags
property is used to add tags to the deployment script.
Here are all the other functions exported in both 01-deploy-box.js
, and 02-deploy-boxV2.js
verify
fromhelper-functions.js
file
const { run } = require("hardhat")
const verify = async (contractAddress, args) => {
console.log("Verifying contract...")
try {
await run("verify:verify", {
address: contractAddress,
constructorArguments: args,
})
} catch (e) {
if (e.message.toLowerCase().includes("already verified")) {
console.log("Already verified!")
} else {
console.log(e)
}
}
}
module.exports = {
verify,
}
verify
that uses the hardhat
library to verify the bytecode of a smart contract deployed at a specific address and with specific constructor arguments.
The function logs a message indicating that the verification process has started and then calls the run
method from the hardhat
library with the verify:verify
task, passing in the contractAddress
and args
as arguments.
If the verification is successful, the function completes without any further action. If the contract has already been verified, the function logs a message indicating that it has already been verified. Otherwise, if there is an error during the verification process, the function logs the error message.
2. hardhat.config.js
require("@nomiclabs/hardhat-waffle")
require("@nomiclabs/hardhat-etherscan")
require("hardhat-deploy")
require("solidity-coverage")
require("hardhat-gas-reporter")
require("hardhat-contract-sizer")
require("dotenv").config()
require("@openzeppelin/hardhat-upgrades")
/**
* @type import('hardhat/config').HardhatUserConfig
*/
const MAINNET_RPC_URL =
process.env.MAINNET_RPC_URL ||
process.env.ALCHEMY_MAINNET_RPC_URL ||
"https://eth-mainnet.alchemyapi.io/v2/your-api-key"
const SEPOLIA_RPC_URL =
process.env.SEPOLIA_RPC_URL || "https://eth-sepolia.g.alchemy.com/v2/YOUR-API-KEY"
const POLYGON_MAINNET_RPC_URL =
process.env.POLYGON_MAINNET_RPC_URL || "https://polygon-mainnet.alchemyapi.io/v2/your-api-key"
const PRIVATE_KEY = process.env.PRIVATE_KEY
// optional
const MNEMONIC = process.env.MNEMONIC || "your mnemonic"
// Your API key for Etherscan, obtain one at https://etherscan.io/
const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY || "Your etherscan API key"
const POLYGONSCAN_API_KEY = process.env.POLYGONSCAN_API_KEY || "Your polygonscan API key"
const REPORT_GAS = process.env.REPORT_GAS || false
module.exports = {
defaultNetwork: "hardhat",
networks: {
hardhat: {
// // If you want to do some forking, uncomment this
// forking: {
// url: MAINNET_RPC_URL
// }
chainId: 31337,
},
localhost: {
chainId: 31337,
},
sepolia: {
url: SEPOLIA_RPC_URL,
accounts: PRIVATE_KEY !== undefined ? [PRIVATE_KEY] : [],
// accounts: {
// mnemonic: MNEMONIC,
// },
saveDeployments: true,
chainId: 11155111,
},
mainnet: {
url: MAINNET_RPC_URL,
accounts: PRIVATE_KEY !== undefined ? [PRIVATE_KEY] : [],
// accounts: {
// mnemonic: MNEMONIC,
// },
saveDeployments: true,
chainId: 1,
},
polygon: {
url: POLYGON_MAINNET_RPC_URL,
accounts: PRIVATE_KEY !== undefined ? [PRIVATE_KEY] : [],
saveDeployments: true,
chainId: 137,
},
},
etherscan: {
// npx hardhat verify --network <NETWORK> <CONTRACT_ADDRESS> <CONSTRUCTOR_PARAMETERS>
apiKey: {
sepolia: ETHERSCAN_API_KEY,
polygon: POLYGONSCAN_API_KEY,
},
},
gasReporter: {
enabled: REPORT_GAS,
currency: "USD",
outputFile: "gas-report.txt",
noColors: true,
// coinmarketcap: process.env.COINMARKETCAP_API_KEY,
},
contractSizer: {
runOnCompile: false,
only: ["Box"],
},
namedAccounts: {
deployer: {
default: 0, // here this will by default take the first account as deployer
1: 0, // similarly on mainnet it will take the first account as deployer. Note though that depending on how hardhat network are configured, the account 0 on one network can be different than on another
},
player: {
default: 1,
},
},
solidity: {
compilers: [
{
version: "0.8.8",
},
{
version: "0.4.24",
},
],
},
mocha: {
timeout: 200000, // 200 seconds max for running tests
},
}
This is a configuration file for Hardhat, a popular development environment for Ethereum smart contracts. The configuration file specifies the various settings and options for the Hardhat environment, including network configurations, compiler versions, deployment settings, and testing options.
This file includes the following:
A list of required plugins including
Hardhat Waffle
,Hardhat Etherscan
,Hardhat Deploy
,Solidity Coverage
,Hardhat Gas Reporter,
andHardhat Contract Sizer
.Configuration settings for several networks including
Hardhat
,local
,Sepolia
,Mainnet
, andPolygon
.Configuration settings for Etherscan and Polygonscan APIs.
Gas reporting settings including enabled/disabled, currency, output file, and CoinMarketCap API key.
Configuration settings for named accounts including the deployer and player.
Solidity compiler versions to use.
Mocha testing settings, including a timeout of 200 seconds for running tests.
Overall, this configuration file is used to set up the Hardhat environment for efficient and effective smart contract development and testing.
helper-hardhat-config.js
const networkConfig = {
default: {
name: "hardhat",
},
31337: {
name: "localhost",
},
11155111: {
name: "sepolia",
},
1: {
name: "mainnet",
},
};
const developmentChains = ["hardhat", "localhost"];
const VERIFICATION_BLOCK_CONFIRMATIONS = 6;
module.exports = {
networkConfig,
developmentChains,
VERIFICATION_BLOCK_CONFIRMATIONS,
};
The code above defines a JavaScript module that exports three variables:
networkConfig
: an object that maps network IDs to network names. Four networks are defined: "hardhat" (default), "localhost" (ID 31337), "sepolia" (ID 11155111), and "mainnet" (ID 1).developmentChains
: an array that contains the names of the networks that are considered to be for development purposes. In this case, it includes "hardhat" and "localhost".VERIFICATION_BLOCK_CONFIRMATIONS
: a constant that represents the number of block confirmations required for a transaction to be considered verified. In this case, it is set to 6.
Overall, the code is defining some configuration settings related to network IDs, network names, and block confirmation requirements, which can be used by other parts of our application.
.env
file
SEPOLIA_RPC_URL='https://eth-sepolia.g.alchemy.com/v2/YOUR-API-KEY'
POLYGON_MAINNET_RPC_URL='https://rpc-mainnet.maticvigil.com'
ALCHEMY_MAINNET_RPC_URL="https://eth-mainnet.alchemyapi.io/v2/your-api-key"
ETHERSCAN_API_KEY='YOUR_KEY'
POLYGONSCAN_API_KEY='YOUR_KEY'
PRIVATE_KEY='abcdefg'
MNEMONIC='abcdefsgshs'
REPORT_GAS=true
COINMARKETCAP_API_KEY="YOUR_KEY"
SEPOLIA_RPC_URL
: This environment variable specifies the URL for the Sepolia RPC endpoint, which is used for interacting with the Ethereum blockchain. This particular endpoint is provided by Alchemy, and requires an API key to access it.POLYGON_MAINNET_RPC_URL
: This environment variable specifies the URL for the Polygon Mainnet RPC endpoint, which is used for interacting with the Polygon network. This particular endpoint is provided by MaticVigil, and does not require an API key to access it.ALCHEMY_MAINNET_RPC_URL
: This environment variable specifies the URL for the Alchemy Mainnet RPC endpoint, which is also used for interacting with the Ethereum blockchain. This particular endpoint is provided by Alchemy, and requires an API key to access it.ETHERSCAN_API_KEY
: This environment variable specifies the API key for Etherscan, which is a blockchain explorer that provides information about transactions, addresses, and blocks on the Ethereum blockchain.POLYGONSCAN_API_KEY
: This environment variable specifies the API key for Polygonscan, which is a blockchain explorer that provides information about transactions, addresses, and blocks on the Polygon network.PRIVATE_KEY
: This environment variable specifies the private key for a particular Ethereum account. This key is used for signing transactions and authenticating with the network.MNEMONIC
: This environment variable specifies a mnemonic phrase that can be used to generate a private key for an Ethereum account. This is an alternative way to specify the private key, and can be useful for managing multiple accounts.REPORT_GAS
: This environment variable specifies whether or not to report the gas cost of each transaction. If set totrue
, gas cost will be reported.COINMARKETCAP_API_KEY
: This environment variable specifies the API key for CoinMarketCap, which is a website that provides information about cryptocurrency prices and market capitalization.
After setting well the environment, let’s deploy the 2 contracts by running the following command:
yarn hardhat deploy
To Upgrade the contract, let’s create our scripts
folder and create upgrade-box.js
, let’s add the script to upgrade our contract Box
to BoxV2
in upgrade-box.js
write the following code:
const { developmentChains, VERIFICATION_BLOCK_CONFIRMATIONS } = require("../helper-hardhat-config")
const { network, deployments, deployer } = require("hardhat")
const { verify } = require("../helper-functions")
async function main() {
const { deploy, log } = deployments
const { deployer } = await getNamedAccounts()
const waitBlockConfirmations = developmentChains.includes(network.name)
? 1
: VERIFICATION_BLOCK_CONFIRMATIONS
log("----------------------------------------------------")
const boxV2 = await deploy("BoxV2", {
from: deployer,
args: [],
log: true,
waitConfirmations: waitBlockConfirmations,
})
// Verify the deployment
if (!developmentChains.includes(network.name) && process.env.ETHERSCAN_API_KEY) {
log("Verifying...")
await verify(boxV2.address, [])
}
// Upgrade!
// Not "the hardhat-deploy way"
const boxProxyAdmin = await ethers.getContract("BoxProxyAdmin")
const transparentProxy = await ethers.getContract("Box_Proxy")
const upgradeTx = await boxProxyAdmin.upgrade(transparentProxy.address, boxV2.address)
await upgradeTx.wait(1)
const proxyBox = await ethers.getContractAt("BoxV2", transparentProxy.address)
const version = await proxyBox.version()
console.log('New version : ',version.toString())
log("----------------------------------------------------")
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error)
process.exit(1)
})
The script performs the following steps:
It imports some necessary modules from the
helper-hardhat-config
andhelper-functions
files.It defines an asynchronous function called
main
that will be executed when the script is run.The function gets the named accounts using the
getNamedAccounts
function from Hardhat.It determines the number of block confirmations to wait for before considering the deployment successful. If the network is a development network, the number of block confirmations is set to 1, otherwise, it is set to a constant value defined in the
helper-hardhat-config
file.It deploys the new version of the contract using the
deployments.deploy
function from Hardhat, passing in the necessary arguments such as the name of the contract, the address of the deployer, and the number of block confirmations to wait for.If the network is not a development network and an
Etherscan API key
is provided, the script attempts to verify the deployment of the new contract using theverify
function from thehelper-functions
file.The script then upgrades an existing contract instance to use the new
version
. It first retrieves the contract instances for theBoxProxyAdmin
contract and the existingBox_Proxy
contract using theethers.getContract
function from Hardhat. It then calls theupgrade
function on theBoxProxyAdmin
contract, passing in the address of the existingBox_Proxy
contract and the address of the newly deployedBoxV2
contract. Finally, it retrieves the contract instance for the upgradedBoxV2
contract and logs itsversion
.The function is called at the end of the script using the
main()
function and any errors are logged to the console.
to check the upgrade by running the commands below:
yarn hardhat node
yarn hardhat run scripts/upgrade-box.js --network localhost
The output will be:
New version : 2
In conclusion, transparent upgradable smart contracts are a powerful tool for blockchain developers. They allow for seamless upgrades to deployed contracts without disrupting the network or requiring users to switch to a new version of the contract. In this article, we explained the concept of transparent upgradable smart contracts and demonstrated an example implementation using two Solidity contracts and their deployment scripts.
Subscribe to my newsletter
Read articles from Muhindo Galien directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Muhindo Galien
Muhindo Galien
I am a Software Engineer with a strong focus on full-stack web3 dev #open_to_work