Deciphering Upgradeable Contracts: Understanding Upgradeable Smart Contracts from a Developer’s Perspective

Zaryab Afser
Coinmonks

--

Source: Indorse

One can never deny the fact that there are some really imperative features that make Smart Contracts enormously reliable.

One such crucial feature is its IMMUTABILITY.

The fact that no one can add, modify or eradicate any part of a Smart Contract once deployed, is what makes it incredibly secure and trustworthy.

While this immutable nature of Smart Contracts might appear to be a boon, there exist some dark sides of it that can’t be overlooked.

The UGLY Face of IMMUTABILITY

The world of software development strongly relies on its ability to upgrade codes thus enhancing the performance of software with every new release.

Well, a typical Smart Contract Never ALLOWS TO UPGRADE ITS CODES.

This is exactly where we begin to observe the dark side of immutable Smart Contracts.

Since there is no deny in the fact that developing a completely Bug-Free code is an incredibly troublesome task, Smart contract’s immutable nature makes it even worse by not allowing to rectify the errors.

Worst Impact on the DeFi Sector
Truth be told, the Crypto world has already witnessed enormous losses worth millions due to small bugs in the contracts that couldn’t be rectified.

For instance, the 17th of June 2016 witnessed one of the biggest hacks in the history of the crypto community, i.e, The DAO Hack.
A total amount of 3.6 million Ether($50 million USD) was stolen by the hacker.

The reason behind this hack was a simple Re-Entrancy bug that could have been solved if Smart Contracts were upgradeable.

Therefore while the immutable nature of Smart Contracts makes it reliable, an ability to upgrade smart contracts ensures utmost security as well.

And this exactly where Upgradeable Smart Contracts comes in.

What exactly does Upgradeable Contract mean?

It’s now quite clear that Smart Contracts are quite rigid.

Once deployed, there is no way to alter a Smart Contract, even if the modification enhances the code.

Now, this is exactly the issue that Upgradeable Smart Contracts addresses.

In simpler terms, Upgradeable Smart Contract is a procedure of effectively upgrading(modifying) a smart contracts while preserving the state, address as well as the balance of the original contract.

Although it might sound really effortless, its an incredibly complex procedure to develop upgradeable contracts.

Major BLOCKERS while writing Upgradeable Contracts

A typical upgradeable contract will undoubtedly include enormous complexities within the process of its development.

For instance, even a basic upgradeable contract development would require:

  • Creation & Deployment of the new version of the existing contract with modified code all over again.
  • Manual Migration of States: Developers must manually migrate all the imperative states from the old contract to the new one.
  • Handling Unpreserved Contract Address: Deploying a new contract will completely change the address of the contract that was previously used for contract interaction.

Hence this complicates the entire scenario enormously as we need to update all contract addresses so that the new contract is used instead of the old one.

Well, there is no denial in the fact that the above-mentioned procedure is not at all an effective one.

Therefore, the community has already witnessed quite a few approaches to develop upgradable smart contracts like:

  • Separate logic and data
  • Master-Slave Contracts
  • Partially Upgradable smart contracts system.
  • Eternal Storage contracts etc.

However, the most effective of them all is the Proxy Contracts Methodology.

In-depth Analysis of Proxy Contract Methodology

While working with this approach, we take into consideration 3 major components:

  • Logic Implementation ContractThis contract is responsible for all the logic and working mechanism.
  • Proxy ContractContains the functionalities for Delegate Call
    Its major function is to delegate the calls to the logic contract.
  • Key Storage ContractContains the Shared State. Most importantly, this contract must be inherited by both Logic & Proxy contract.

But…What Exactly is a Delegate Call?

Delegate Call actually lies at the heart of the Proxy Contract approach and therefore is an incredibly crucial concept to understand.

DELEGATE CALL is an important opcode provided by the EVM that enables us to execute the code at the target contract address, but in the context of the calling contract.

Let’s understand this.

Understanding DELEGATE CALL with Simple CALL

In the case of Simple CALL

  • When contract A(caller contract) does a simple CALL on B(target contract), the code actually gets executed in the context of B.

It means Storage of B is used.

  • Moreover, Contract B assumes that the actual caller is Contract A and not an Externally Owner Account(although that’s not at all true).

Hence Simple Calls are not capable of preserving the msg.sender or msg.value

In case of DELEGATE CALL

  • Delegate call, unlike simple CALL, is much more modified and effective.
  • When Contract A does a Delegate call on Contact B, the code gets executed in the context of Contract A(the Calling Contract) and not Contract B(the Target Contract).

It means the storage of A is used.

  • Moreover, in a Delegate Call msg.sender and msg.value is preserved.
  • For example, during a delegate call Contract B(target contract) is aware that the actual caller is not Contract A(caller contract) but an externally owned account(msg.sender).

Brief Glance at the Working Mechanism

With that being said, its time to explore how the Proxy Contract approach uses Delegate Call and helps us develop Upgradable Smart Contracts.

One of the most imperative reason behind the effectiveness of this approach is the fact that the end-users only need to interact with the proxy contract.

Since we have separated the logic contract, we can change the logic while keeping the proxy contract same for the user.

Thus, the Proxy Contract in this procedure acts as immutable storage while the Logic Contract will include all the functionalities.

Now, to upgrade the logic of the contract, we simply need to make the proxy contract aware of the address of the new delegate contract. Therefore, whenever we invoke a particular function in the Proxy Contract, it simply Delegate Calls the Logic contract(which contains the function logic).

Therefore, whenever we invoke a particular function in the Proxy Contract, it simply Delegate Calls the Logic contract(which contains the function logic).

Thus, enabling us to use the modified code while interacting with the same old Proxy Contract with the state and address of the contract completely preserved.

Source: openzeppelin

The structure of storage, i.e., the order of state variables must be similar for both Proxy and Logic Contract.

This is one of the most imperative requirements for Delegate Call to work with utmost adequacy.

Careful observation might lead us to the fact that even if we choose the Proxy Contract methodology, developing an upgradeable smart contract is an incredibly troublesome process.

Since there are enormous details to be considered while developing upgradeable contracts, writing one from scratch every time will undoubtedly lead us to undesirable results.

Therefore, there is a very strong need for tools or libraries that provide robust, easy to use, and effective upgrade mechanism for smart contracts.

Quite fortunately, these are exactly the issues that Openzeppelin Upgrades CLI effectively addresses.

Enters Openzeppelin

Openzepplin Upgrades CLI has completely eradicated the complexities involved in developing Upgradable Contracts.

Let’s write an upgradable contract with openzepplin CLI to understand how exactly it works.

Note: Openzeppelin CLI demands Node.js for development. Make sure you have it pre-installed.

Initiate Project

  1. Let’s begin by creating a separate folder.

$ mkdir test-upgradable-contracts && cd test-upgradable-contracts

Once inside the folder, create a npm package

$ npm init -y

Now, let’s install the Openzepplin CLI

$ npm i @openzeppelin/cli

Since the entire process will involve creating, compiling and deploying smart contracts, we must have a local blockchain installed.

Run the following command to install it.

$ npm install ganache-cli

Great!

Now that we are done with installations, let's initiate a CLI project.
Run the following command.

$ npx oz init

Once this command is successfully executed you have:

network.js file

  • This is where the CLI stores the network configurations.
module.exports = {
networks: {
development: {
protocol: 'http',
host: 'localhost',
port: 8545,
gas: 5000000,
gasPrice: 5e9,
networkId: '*',
},
},
};

.openzeppelin Directory

  • Here every project related details are stored.
{
"manifestVersion": "2.2",
"contracts": {},
"dependencies": {},
"name": "test_upgrades",
"version": "1.0.0",
"compiler": {
"compilerSettings": {
"optimizer": {}
},
"typechain": {
"enabled": false
}
}
}

Write and Deploy the Contract

Now that we have the tool ready, let’s write a basic contract.

Go to contracts directory and create a solidity file.

$ cd contracts

$ touch Invest.sol

Understanding the Contract
In simpler terms, this contract allows user to invest ETH in the contract and withdraw them whenever they wish.

The functionalities in the contract are as follows:

  • A mapping that stores the invested amount of each user address.
  • An invest function that let’s user invest the ETH in the contract.
  • Withdraw function that allows a particular user to withdraw the invested ETH and updates the state of the contract.
  • getContractBalance returns the balance of the contract.
  • getUserBalance returns the balance of a particular user.
pragma solidity ^0.5.0;contract Invest{  mapping (address => uint) public userBalances;  function invest() public payable{
userBalances[msg.sender] = msg.value;
}
function withdraw() public{
require (userBalances[msg.sender] != 0, "User doesn't have any balance");
address payable rec = msg.sender;
rec.transfer(userBalances[msg.sender]);
userBalances[msg.sender] = 0;
}

function getContractBalance() public view returns(uint256){
return address(this).balance;
}
function getUserBalance(address _user) public view returns(uint256){
return _user.balance;
}
}
}

Deploying the Contract

Before deploying the contract we must ensure that the local blockchain(Ganache-CLI) is running properly.

Initiate Ganache CLI

Go to a new terminal and run ganache-cli to start the local blockchain

Now Ganache is listening on port 8545.

Once the Openzeppelin CLI connects with the ganache, we can access the 10 accounts using the command:

$ npx oz accounts

Let’s start deploying our contract.

In order to deploy the Counter contract, run the following:

$ npx oz deploy

This gives you a few options to choose the kind of deployment you wish.

Since we want an upgradable contract, choose the upgradable option.

Once selected your contract is all set to be deployed on the local blockchain.

The contract has now been successfully deployed.

Contract Address 0x5A35A10974D37B63CFc01f3De24785DDBdd10A10

Now let’s interact with our contract.
Openzeppelin makes it incredibly easy to interact with our contract.

In fact, to simplify the entire process they break it down into 2 imperative parts.

  • Sending a transaction
  • Calling a particular component to check the updated changes

Note: In order to interact with our contract, we must ensure the local blockchain is already running at port 8545

Initial State of the Contract
Before investing in the contract, let’s check the initial state of our contract.

User Balance

We called the getUserBalance function, and it shows the user has 94 ETH.

Our of all the 10 accounts, we are using the first test account whose address is 0xA463FF6CC6d54D5EA4F3B1E7A5C5FA58c52F3603

Contract Balance

Since there is no ETH invested yet, the contract Balance is 0.

Let’s deposit some Ether in the contract.

Sending a Transaction
In order to send a transaction, we need to run:

$ npx oz send-tx

In our case, we have two major functions:

  • Invest
  • Withdraw

Let’s first invest some amount in the contract.

Note: Sending Ether to a contract with Openzepplin CLI might seem a bit complex initially but is fairly simple once understood.

In order to pass some ether in the contract we need to run the command:

$ npx oz send-tx — value <value>

Here value is the amount in wei that will be transferred to the contract.

Now let’s call the invest function to deposit some eth in the contract.

Hence we transferred 5000000000000000000 WEI which means 5 Ether using the first test account of ganache.

Once the transaction is successful, we receive the transaction hash.

We can now proceed further to check if the ETH was deposited in the contract.

Checking Contract Balance
In order to check the balance, we simply call the getContractBalance function by running the command:

$ npx oz call

Thus, we have 5 Ether deposited in the contract.

Moreover, the user balance should also get deducted. Let’s check the balance by calling getUserBalance.

The balance now is 94–5 = 89 ETH.

Withdrawing the ETH

As we can see, once we call the withdraw function, the deposited eth is sent back to the user’s address and the contract balance is back to 0.

Let’s UPGRADE

Let’s upgrade our contract by modifying or adding some rules to the withdrawal function.

As per the modified withdrawal function, user can only withdraw the deposited ETH after a particular time period.

Let’s modify the contract accordingly.

Modified Contract

pragma solidity ^0.5.0;contract Invest{  mapping (address => uint) public userBalances;
mapping(address => uint) public lockTime;
function invest() public payable{
userBalances[msg.sender] = msg.value;
lockTime[msg.sender] = now + 1 weeks;
}


function withdraw() public{
require (now > lockTime[msg.sender], "Deadline is not over yet");
require (userBalances[msg.sender] != 0, "User Doesn't have any balance");
address payable rec = msg.sender;
rec.transfer(userBalances[msg.sender]);
userBalances[msg.sender] = 0;
}

function getContractBalance() public view returns(uint256){
return address(this).balance;
}
function getBalance(address _user) public view returns(uint256){
return _user.balance;
}

}
}

Note: The Code above is only used as an example.

The CODE MUST NOT BE USED IN PRODUCTION

As per the modified changes, we have added a new mapping called lockTime.

This mapping stores the deadline for every user address after which the users will be able to withdraw their balance.

In order to upgrade our contract, we run the following command:

$ npx oz upgrade

Our contract with all the necessary changes has now successfully been upgraded.

One of the most imperative parts that must be noted is the fact that the Contract addresses remains the same.

Contract Address after Upgrade: 0x5A35A10974D37B63CFc01f3De24785DDBdd10A10

Testing the Upgraded Contract

Since the contract has now been successfully upgraded, let’s check if it works as expected.

Depositing money in the Contract

Once the transaction is executed properly, we can see that the 5 ether is once again deposited to the contract as expected.

Now, let’s try to withdraw the deposited amount.

Withdrawing ETHER
Since we have updated the withdraw function, the function won’t work as before.

As per the latest changes, withdrawal of the deposited amount is only possible after 1 week.

Therefore, let’s check if the function has been upgraded and works as per our expectation.

As soon as we run the command npx oz send-tx on the withdraw function, we find that the execution of the function fails(as expected).

Thus, the error message “DEADLINE IS NOT OVER YET”, clearly shows that we have successfully upgraded our contract with desired changes while effectively preserving the state and address of the previous contract.

Imperative Details that MUST BE NOTED

Although the Openzepplin CLI simplifies the process of writing upgradable smart contracts, there are a few crucial details that cannot be overlooked.

While these details might appear as limitations of the Openzepplin tool, but the actual reason for their existence lies in the internal working mechanism of the Ethereum Virtual Machine.

No Constructors in Upgradeable Contracts

Upgradable Contracts cannot have CONSTRUCTORS.

However, since constructors are of utmost importance, the openzeppelin upgradeable tool provides us with a base contract called Initializable.

Initializable.sol

pragma solidity >=0.4.24 <0.7.0;
contract Initializable {
bool private initialized;
bool private initializing;
modifier initializer() {
require(initializing || isConstructor() || !initialized, "Contract instance has already been initialized");
bool isTopLevelCall = !initializing;
if (isTopLevelCall) {
initializing = true;
initialized = true;
}
_; if (isTopLevelCall) {
initializing = false;
}
}
address self = address(this);
uint256 cs;
assembly { cs := extcodesize(self) }
return cs == 0;
}
uint256[50] private ______gap;
}

Initializable base contract effectively addresses this constructor issue.

Instead of the constructor, we simply need to write a initialize function that will act as the constructor(after inheriting the Initializable.sol contract).

While deploying the upgradable contract the Openzepplin CLI will automatically prompt us to execute the initializer function. It will also allow us to pass parameters(if any) to the initializer function.

While working with constructors its imperative to ensure that the constructor is called only once.

Thus, in this case, the “initializer()” modifier takes care of this issue.It ensures that the constructor has been called only once in the upgradable contract.

Initializing Variables with Values

While Solidity allows us to initialize variables with a fixed value, such a task is not allowed in the upgradable contract.

For instance:

pragma solidity ^0.5.0;contract Counter {
//Such initializations aren't possible in Upgradable contracts.
uint256 public value = 1000;
function increase() public {
value += 1;
}
}

Solution
However, an effective solution to this is to initialize the values inside the initialize() function(constructor) of the contract.

pragma solidity ^0.5.0;import "@openzeppelin/upgrades/contracts/Initializable.sol";contract Counter {    uint256 public value;    function initialize() initializer public {
value = 1000;
}
function increase() public {
value += 1;
}
}

Storage LAYOUT is CRUCIAL

As discussed earlier, DELEGATE CALLS between contract will work effectively only when we ensure that both contracts have similar storage layout.

Therefore, a very crucial fact of upgradable contracts is that while upgrading an old contract we are not allowed to change its storage layout even slightly.

This simply means that while upgrading a contract, we cannot remove previously declared state variables or change its type.

What if we want to add new State Variables?
As per the rules, we cannot add new state variables before the old ones.

All the new state variables must be added after the previously declared variables so that the Storage Layout is not disturbed.

Hence, keeping these few imperative details in mind will undoubtedly help us write better and effective upgradable smart contracts.

About myself

Who am I? 🙋🏻‍♂️

𝙃𝙞, 𝙄 𝙖𝙢 𝙕𝙖𝙧𝙮𝙖𝙗 👋🏻
I am a proficient Blockchain and Smart Contract Engineer with a vision of Decentralizing and Securing the traditional Web with Web3. Mostly work on Smart Contracts with significant experience in both Development and Smart Contract Security.

What I Do 🧑🏼‍💻

  • I write secure and optimized Smart Contracts
  • I perform security audits on smart contracts and enhance the overall security of smart contracts on EVM chains
  • I write and speak about Web3 and Smart Contracts & contribute my part towards expanding the boundaries for Web3.

Drop a ‘HI’ and Get in Touch 🤝

Linkedin. | Twitter. | Github. | Invite me for Web3 Events

Join Coinmonks Telegram Channel and Youtube Channel learn about crypto trading and investing

Also, Read

--

--

Zaryab Afser
Coinmonks

Lead Smart Contract Engineer @ Push Protocol| Smart Contract Security Auditor | Educating the World about Web3, Smart Contracts & Security in DeFi