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 event ValueChanged 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 is 1 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 eventValueChanged
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 eventValueChanged
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
and deployments. - The function uses the deployments object to deploy the
Box
smart contract. It retrieves the deployer account fromgetNamedAccountsand
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 the deployer account, with no arguments for the constructor, and logging set to true. Additionally, it specifies thewaitBlockConfirmations
variable, and a proxy object is provided with theproxyContract
andviaAdminContract
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.
In02-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
, and Hardhat Contract Sizer. - Configuration settings for several networks including
Hardhat
,local
,Sepolia
,Mainnet
, andPolygon
. - Configuration settings for
Etherscan
andPolygonscan
APIs. - Gas reporting settings including enabled/disabled, currency, output file, and
CoinMarketCap
API key. - Configuration settings for named accounts including the
deployer
andplayer
. - 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.
3.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 includeshardhat
andlocalhost
. -
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.
4..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 to true, 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 and
helper-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 fromHardhat
. - 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 thehelper-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 thename
of thecontract
, theaddress
of thedeployer
, and the number ofblock 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 the
BoxProxyAdmin
contract and the existingBox_Proxy
contract using theethers.getContract
function from Hardhat. It then calls theupgrade
function on theBoxProxyAdmin
contract, passing in theaddress
of the existingBox_Proxy
contract and theaddress
of the newly deployedBoxV2
contract. Finally, it retrieves the contract instance for the upgradedBoxV2
contract and logs its version. - 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.
Useful resources:
1.https://github.com/wighawag/template-ethereum-contracts
2.https://docs.openzeppelin.com/upgrades-plugins/1.x/
3.https://github.com/wighawag/template-ethereum-contracts/tree/examples/openzeppelin-proxies/deploy
Top comments (0)