Get the most out of Upgradeable Smart Contracts
Please Note: From here on out, I have migrated to Hashnode as my official Tech Blog and publish weekly. If you’d like more of such content on all things Web3, feel free to subscribe to my Blog Bored on The Edge.
It's been a long time since I last posted on Medium. If you have been following me on Dev.to or Hashnode, this is the fourth article in my series on Upgradeable Smart contracts. We have been through writing upgradeable smart contracts, testing them out and then deploying version 1 followed by version 2 on Polygon’s Mumbai Testnet. You can find those at any of the aforementioned tech blogs.
While the development cycle is complete at this point, things are still left to discuss. More specifically, points to note from all these exercises and recap them all at once. This article will aim to do that. We will go through most of the things one needs to remember while developing an upgradeable smart contract. Remember that the whole “upgradeability” paradigm is comparatively new, so these points are not de facto yet. If you feel like there are points I might have missed out on or would like to initiate a discussion on the contrary, feel free to drop a comment.
UUPS vs Transparent
Transparent and UUPS patterns are two of the methods of upgrading smart contracts. They were adopted by EIPs 1538 and 1822 respectively. Openzeppelin offers both options in upgradeable smart contracts. But over time, 1822 or the UUPS pattern has grown to be adopted by the community (and with good reason).
If you wish to understand the difference between the two then consider this — both standards have a proxy in front of another contract. But while the Transparent pattern is changing the proxy for every upgrade, UUPS changes the implementation recorded and keeps the proxy contract constant.
While the Transparent proxy pattern takes less gas for deployment, UUPS has been proven to be more gas efficient in the long run. It is also easier to develop both smart contract-wise and frontend-wise. If you are considering upgradeable smart contracts, then the UUPS proxy pattern is the way in my opinion.
Understand Initialization Phase
The initialization phase of an upgradeable smart contract is one of the most important phases. If not properly handled, it can turn a smart contract with perfect business logic implementation into a hacker’s plaything.
Time and again in this series, I have emphasized that the initialization phase of an upgradeable smart contract is different from a normal one. There are no constructors in most of the upgradeable smart contracts because of the proxy design.
The implementation cannot store any data. It is just pure business logic. This is why most of the stuff that goes into a constructor of a normal contract needs to go to the special “initializer” function. But unlike the constructor which only gets called during smart contract deployment, the initializer function needs an extra layer of security because at its core it is just another normal smart contract function. Hence all the fuzz.
Most smart contracts use Openzeppelin/contracts. Those developers dabbling in Upgradeable smart contracts use its sister project Openzeppelin/contracts-upgradeable. The two are the same albeit the constructor in the contract is replaced by the initializer.
Openzeppelin/contract-upgradeable’s Initializable.sol is a smart contract which I would recommend every smart contract developer to understand in-depth. It basically contains all the logic needed for the initialization of upgradeable smart contracts. The initializer modifier is the one we will be discussing in this sub-section.
If you want, you might just go ahead and implement the same stuff with a simple boolean variable. This variable will be set to true when the upgradeable smart contract is deployed. The logic is initializer function can only be invoked if this Boolean variable is false. And that’s it. This can actually prevent a niche hijacking attack on your upgradeable smart contract.
The modifier provided by Openzeppelin though goes beyond the call of duty. It checks the contract being deployed and provides a special functionality — invoking functions only DURING the initialization phase.
The initializer function in your contract is where you initialize other upgradeable contract’s _init() method like say the __UUPSUpgradeable_init(). All you need to secure your initializer is thus use the initializer modifier from Openzeppelin’s Initializable.sol.
A re-initializer is invoked during upgrades. This is the initializer for your next versions. The initializer modifier can only be used once in the whole lifespan of your contract across all the versions. So, what happens if you want to add specific functionality in the second function?
That’s where the reinitializer(<integer type version>) modifier comes in. This too is supplied by Initializable.sol and can be thought of as a generalized version of the initializer. In fact, passing version number 1 in the modifier is the same as using the initializer modifier.
Keep in mind this is only incremental. You can try to use reinitializer(2) after using reinitializer(3) and it won’t work. This is because when you inherit Initializable.sol, you also inherit the mechanism to incrementally upgrade and the checks associated with that. So, if you want to roll back, you will need to either change the record of the implementation contract from the proxy or deploy the older version contract as the next version.
Also, you need to note that when invoking the re-initializer, your contract will go through an “initializing” phase and that too is provided by this modifier.
The _disableInitializer() function prevents any further initialization. Keep in mind that when you upgrade your contract you need to follow a common convention of inheriting the previous version and then adding any modifications. It is not warranted that you will need to initialize something every time. So, you shouldn’t confuse this as preventing any upgrades.
Conventionally, this function is used inside the constructor of the implementation contract. Does this line ring an alarm? After all, upgradeable smart contracts cannot have any constructors, right?
The thing is upgradeable smart contracts cannot have any constructors which initialize any variable data. You can have a constructor that initializes an immutable (constants are initialized inline). This is pretty unsafe so you need the
/// @custom:oz-upgrades-unsafe-allow constructor
At this point, you also need to note that when you inherit an older version and then create a new version, all the initializer functions in the older versions will be executed during contract deployment. A quick way to test this out is to put events inside initializer functions across versions and then check the events which have been emitted right after the new implementation contract is deployed. We will discuss this in a later section.
This means if you have used the _disableInitializer() function in any of the previous versions, the storage state will be initialized up to that specific version in the current implement contract. Use it wisely.
Transparent Storage EIP
The EIP-1967 is what brought the Transparent Storage pattern to the ecosystem. By now you should be aware that while you can upgrade your implementation contract, the storage space shared across all versions remains the same.
It is kind of like the backing up of storage disks attached to cloud VMs. The storage space gets incrementally changed as versions are deployed. This means that if you had a certain number of variables already declared in previous versions, that space will persist in the next version as well and you need to define those exact variables in the same order in newer versions.
While it is not mandatory to understand the complete EIP, I would recommend going through it at least once because it can immensely influence how you design your smart contracts.
Inherit from Thy Ancestors
This brings us to the current point — why you should practice inheritance in smart contracts. The answer is quite simple — because you need to declare the variables in the previous version in exactly the same way before you go about declaring new ones.
Sometimes it might seem simple to just declare those variables and be done with it. This can prove to be a nuance if you are inheriting from a new contract in version 2 (for example to add functionalities). This is not that uncommon. In the non-upgradeable counterpart, you would need to inherit from Chainlink’s Contract to use its oracle services.
The reason it is tricky is the space is allotted to the inherited contracts first and then any other variables in your main contract. So, while you might think you are still declaring the variables in the same order as the previous version you are actually re-declaring new variables and the inherited contract’s variables overlap with the variable storage space of the old contract.
This is why inheriting the previous version is the easiest thing to do. Keep in mind it is recommended you inherit the previous version first followed by any other contract(s). Otherwise, it might lead to problems in the inheritance tree (like linearization). Also, any functionality in the previous version is inherited in the new version. This includes any functionality from Initialization.sol contract or the OwnableUpgradeable.sol contracts.
A healthy mix of Virtual and Override
Upgradeability is not just aimed at introducing new functionalities in the smart contract, it is also aimed at changing existing ones. Inheritance facilitates this as well. You need to keep in mind while designing the initial version of your smart contract that the business logic might change in future versions.
A good design convention here in my opinion would be to create a document to list out all your smart contract methods and then go through them at least twice. This will give you the time to argue which of those might change in the future. The ones which might change in the future — mark them as virtual functions.
Marking any function as virtual will enable you to use the override keyword to override that function in future versions. A point to note here is that both of these keywords can be used at once in the function prototype. This would mean you are overriding the function from the previous version but also are keeping the scope for it to change in future versions.
You can only upgrade so much…
Upgrading your smart contracts will not solve all your problems. For one, it cannot fill in for lousy smart contract design. That catches up eventually. The only way to improve your design is to revise it before actually implementing it. If you are a part of a team, make sure to discuss every functionality before you go into code.
Furthermore, the Initializable.sol contract puts an upper bound on the number of versions a contract might be upgraded to 255. You can implement your own to increase that limit. But honestly, if you need to upgrade your contract that many times, it might just be better to write a new one. After all, if you keep adding to the contract over time and keep changing it ever so slightly… Will it be the same contract at the end?
This brings us to the end. Honestly, it wasn’t one of my longer articles but I wanted to get most of the things I could within this short article. If you think I might have missed anything, please feel free to start a discussion below. It’s always encouraging to see such engagement.
Also, keep in mind that the whole “upgradeable” smart contract is still a new thing at the time of writing and this article might grow as things line up in the future. If you like it, give it a like and a shoutout on Twitter or LinkedIn would make my day!
This also brings us to the end of my article series on upgradable smart contracts. Guess I will start on another interesting topic come next week. But until then, keep building awesome stuff on Web3 and WAGMI!