Upgradeable Smart Contracts in the Wild

Upgradeable Smart Contracts in the Wild

and how to tame them...

This article is a cross-post from my article series on Dev.to.

Back with another article and this time, it will be a series of articles where we will cover a wide range of topics starting from Upgradeable Smart Contracts. This series is aimed at helping you understand how you can develop upgradable smart contracts – not just coding but what kind of a mindset is needed for it. This series will have articles dedicated to:

  1. Hands-on Development and Testing of Upgradeable Smart Contracts.
  2. Testing Upgradeable Smart Contracts.
  3. Deploying Upgradeable Smart Contracts.
  4. The Nuances of writing upgradeable smart contracts.
  5. A Duct-tape solution of sorts for using Chainliand nk’s Oracle contracts with Upgradeable Smart Contract.

I have this tendency of building upon previous mini-projects developed during articles. At this point, it feels like creating an NFT smart contract to demonstrate stuff is equivalent of Todo app. Every web developer learns to build a Todo app whenever testing out new framework.

This article will revolve around the first point above. We will discuss the two most-used paradigms of upgradeability – transfer and UUPS. We will first write a generic smart contract (sort of like a Hello World). Then we will upgrade it to show how business logic can be added via upgrades. The test driven approach for this will be discussed in the next article in this series. There is a lot to cover and so let’s get on with the show.

Contracts

Let’s get busy with writing our smart contracts. At first we will use a simple smart contract – one which only has a greeting() function that returns a string. Then we will upgrade it to (and you know it) – an NFT Smart contract.

A Bland Version 1

You might think that the contract given below is not that important given it only returns a string. As Dwight put it

Dwight

Rest assured, upgrading smart contracts is not really identity-theft.

In the code below, you see the version 1 of our contract – aptly named OurUpgradeableNFT1. There is not “constructor” in the code below. Upgradable smart contracts don’t have a constructor. They have an initializer function (that’s literally what a constructor is though). We inherit from Initializable, UUPSUpgradeable and OwnableUpgradeable in our contract’s version 1 as shown below. These contracts inherited from serve important purposes.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;

import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract OurUpgradeableNFT1 is
    Initializable,
    UUPSUpgradeable,
    OwnableUpgradeable
{
    string public greeting;

    function initialize(string calldata _greeting) public initializer {
        greeting = _greeting;
        __UUPSUpgradeable_init();
        __Ownable_init();
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        override
        onlyOwner
    {}
}

Intializable provides us with the modifier initializer. This helps us in declaring our initializing function. In fact, contrary to convention, the initializing function need not be called “initializer”. You might as well name it “sallysAuntPolly” and it will still work. Shakespeare, thus, rightly say “What’s in the name?”[ref]. It’s not the name of the function rather its purpose that we need to focus on. The initializing function (initialize in the contract above) is supposed to be executed only once. The risk it brings at being invocable even after that is contract hijacking. Initializable provides us with the initializer modifier which makes sure that it can only be invoked once during contract deployment.

UUPSUpgradeable is for providing UUPS proxy-based upgrade functionality. OurUpgradeableNFT1 also inherits the _authorizeUpgrade() function from it. This function needs to be present and overridden (if inherited) for UUPS proxy-based upgrade functionality. You need not have anything inside it – but still it needs to be there. If you are not sure what that is, I will refer you to this awesome explanation video.

{% embed youtube.com/watch?v=kWUDTZhxKZI %}

OwnableUpgradable is the upgradable counterpart of Ownable contract from Openzeppelin used for access control. It provides a modifier onlyOwner whose functionality you can very well guess. Also, like the normal Ownable, it provides a owner() function that returns the address of the owner along with other functionalities which include relinquishing ownership and such. This is important because we want that only the owner should be able to upgrade the contract. This is reflected on the _authorizeUpgrade() function which contains the onlyOwner modifier in the contract above.

Our initialize function in the code above initializes the UUPSUpgradeable and OwnableUpgradeable contracts with __UUPSUpgradeable_init() and __Ownable_init() respectively. The former need not be initialized at the time of writing. Our initialize function also receives a string via _greeting parameter during initialization which it assigns to a public storage variable called greeting.

There is also an easily missed demonstration in the code above which further differentiates constructor in normal smart contracts and initializer in upgradable ones. Notice how the _greeting parameter is defined as **calldata**. If this were a constructor, we would have to use **memory** as constructors are not allowed to have calldata type parameters.

This is mostly it for our version 1. Version 2 is where exciting part arrives.

A slightly less Bland Version 2

Now in the upgrade contract (version 2) we will make our contract an NFT minting contract. Normally, this can be accomplished just by inheriting from ERC721 or ERC721URIStorage but

We don't do that here

In the code below, our version 2 inherits from ERC721URIStorageUpgradable. This contract is mostly identical to the normal Openzeppelin contract with the exception of the constructor being replaced by initializer. If you have used ERC721URIStorage, you should know by now that it inherits from ERC721 so we do not need to inherit from ERC721. Case is similar here. The interesting thing to note here is that we inherit from our previous version i.e. OurUpgradeableNFT1. Now...

Wait a minute

Aren’t we supposed to upgrade that stuff? Why are we inheriting from it? Does that mean that we cannot really replace that code completely? The answer to that is tricky.

Upgradeable Smart contracts share the same storage space. A fact you might be aware of if you watched the video above. This also implies that any “upgrade” we make cannot really delete stuff from that storage but add to it. The functions in the previous contract are a different story though and we will discuss that in a little bit.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;

import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/CountersUpgradeable.sol";

import "./NFTContract1.sol";

contract OurUpgradeableNFT2 is OurUpgradeableNFT1, ERC721URIStorageUpgradeable {
    using CountersUpgradeable for CountersUpgradeable.Counter;

    CountersUpgradeable.Counter private _tokenIdCounter;

    function reInitialize() public reinitializer(2) {
        __ERC721_init("OurUpgradeableNFT", "OUN");
        __ERC721URIStorage_init();
    }

/*
* Remaining Contract Code here
*/

}

In the lines following up to the reInitialize() function above, we inherit from our previous contract (version 1) and from the ERC721URIStorageUpgradeable and we define a counter for keeping track of our NFTs. It’s the reinitialize function that needs more attention. Notice how it contains this modifier reInitializer(2). Where did that come from?

You can search in ERC721URIStorageUpgradeable but you won’t find it. This comes from the Initializable contract we discussed in the previous sub-section. This multi-level inheritance in our smart contract gives us access to that. It is used for deploying upgraded versions. Given that initializer functions replace constructors, it stands to reason that we might need to invoke one every version. The initializer from previous version will be invoked automatically while upgrading the new contract. But we cannot modify that. So, we specify a new one. reinitializer(2) means that this function can only be invoked in version 2.

Points to note here:-

  1. Inheriting from Initializable in previous contract automatically assigns it version 1.
  2. The re-initializer in version 2 needs to be manually invoked. Placing reInitializer(2) modifier just restricts scope of invocation – it does not mean that the function with that modifier will automatically get invoked in version 2.

You might think that perhaps if we inherit from Initializable, UUPSUpgradeable and OwnableUpgradeable then we might not need to inherit from OurUpgradeableNFT1. That is not possible here and we will discuss that in detail in the 3rd article. In the code section below, the rest of the functions we will put into our version are given. We will put these functions in the section of the code above where it says "Remaining Contract Code here".


    function greetingNew() public pure returns (string memory) {
        return "New Upgradeable World!";
    }

    function safeMint(address to, string memory uri) public virtual onlyOwner {
        uint256 tokenId = _tokenIdCounter.current();
        _tokenIdCounter.increment();
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }

    // The following functions are overrides required by Solidity.

    function _burn(uint256 tokenId) internal override {
        super._burn(tokenId);
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

The greetingNew() function is similar to the greeting() function in version 1. The purpose of this function is to show how both of them will co-exist in version 1.

The remaining functions should appear quite generic in nature. If you have used the wonderful that Openzeppelin wizard is, then you will already be familiar with them.

The thing you need to need to notice at this point is that the safeMint() function is declared as virtual. This means that any contract inheriting from OurUpgreadableNFT2 contract will have the option to either:

  1. Implement a new logic for minting NFTS.
  2. Sticking to the old implementation with the help of super.

This paves way for upgradeability in our contract - something that you need to remember when developing upgradable smart contracts.

This completes the coding of smart contracts. While I would love to cover testing and deployment in this article, it might be a too much of an overload for a first-timer. Instead, I would recommend reading through again and noting down few of the nuances of upgradable smart contracts that we have discussed here in this article.

Conclusion

If you have understood up to this point and you are indeed a first-timer wishing to learn how to develop upgradeable smart contracts then kudos to you. If there are still some gaps left which you would like me to fill out then feel free to drop your doubts in the comment section below.

In the next article in this series we will go through how you can test your upgradeable smart contracts and then deploy them. Until then, continue to make awesome things on Web3 and WAGMI!

Did you find this article valuable?

Support Abhik Banerjee by becoming a sponsor. Any amount is appreciated!