Transparent Upgradable Smart Contracts: A Guide with Code Explanation

Muhindo GalienMuhindo Galien
13 min read

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.

  1. The store function stores a new value in the contract and emits an event ValueChanged with the new value as a parameter.

  2. The retrieve function returns the last stored value.

  3. 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:

  1. 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.

  2. retrieve : This function is a view function that returns the last stored value.

  3. 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:

  1. Importing helper modules such as developmentChains and VERIFICATION_BLOCK_CONFIRMATIONS from the helper-hardhat-config file, network from hardhat, and verify from helper-functions

  2. Defining an export function that will be called by the Hardhat deployment script. The function will take two arguments, getNamedAccounts and deployments.

  3. The function uses thedeployments object to deploy the Boxsmart contract. It retrieves the deployer account from getNamedAccountsand uses it as the deployer of the contract.

  4. The waitBlockConfirmations variable is set to 1 if the network is a development chain, else to VERIFICATION_BLOCK_CONFIRMATIONS.

  5. 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 the waitBlockConfirmations variable, and a proxy object is provided with the proxyContract and viaAdminContract properties.

  6. The deployment is then verified by calling the verify function, but only if the network is not a development chain, and the ETHERSCAN_API_KEY environment variable is set.

  7. 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

  1. verify from helper-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:

  1. A list of required plugins including Hardhat Waffle, Hardhat Etherscan, Hardhat Deploy, Solidity Coverage, Hardhat Gas Reporter, and Hardhat Contract Sizer.

  2. Configuration settings for several networks including Hardhat, local, Sepolia, Mainnet, and Polygon.

  3. Configuration settings for Etherscan and Polygonscan APIs.

  4. Gas reporting settings including enabled/disabled, currency, output file, and CoinMarketCap API key.

  5. Configuration settings for named accounts including the deployer and player.

  6. Solidity compiler versions to use.

  7. 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.

  1. 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:

  1. 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).

  2. 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".

  3. 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.

  1. .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"
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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:

  1. It imports some necessary modules from the helper-hardhat-config and helper-functions files.

  2. It defines an asynchronous function called main that will be executed when the script is run.

  3. The function gets the named accounts using the getNamedAccounts function from Hardhat.

  4. 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.

  5. 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.

  6. 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 the verify function from the helper-functions file.

  7. 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 existing Box_Proxy contract using the ethers.getContract function from Hardhat. It then calls the upgrade function on the BoxProxyAdmin contract, passing in the address of the existing Box_Proxy contract and the address of the newly deployed BoxV2 contract. Finally, it retrieves the contract instance for the upgraded BoxV2 contract and logs its version.

  8. 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

10
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