Web3 , Blockchain , Developers

Upgrades in Solidity Smart Contracts – Let’s write an Upgradeable ERC721 Contract

Why? You might ask why do we need upgrades and isn’t that something that completely violates the immutability of Blockchain and Smart Contracts?In a way, yes. Still, we need upgrades, reason is, imagine that you set a mint fee for your NFT SC (Smart Contract) that is equal to 5$ worth of ETH and then ...

Why?

You might ask why do we need upgrades and isn’t that something that completely violates the immutability of Blockchain and Smart Contracts?In a way, yes. Still, we need upgrades, reason is, imagine that you set a mint fee for your NFT SC (Smart Contract) that is equal to 5$ worth of ETH and then you wanted to change that value to 10$ worth of ETH. Why, inflation, demand or anything else. Not just fees, let’s say that you are creating a game that has some player types and you wanted to add a new player type, what will happen then?

Also one more violation is decentralization right? What if the contract upgraded with some kind of bad code, whoever has the ability to upgrade the SC has control over everything with the upgrades right? Centralizing might not be a problem for some cases but what if decentralization is important part of that contract’s purpose? There comes Multisig Upgrades which is basically not having a single entity or wallet who controls the upgrades. First you have to propose an upgrade and the SC will be upgraded only after the owners of Multisig reviews and approves the proposal. Read more about Upgrading via Multisig with Openzeppelin

Approaches

Setter Functions

This is basically defining the values in the SC and changing those values via setter functions. In below code, imagine that we want 0.00005 ether as fee for every request to one of our function and after some time I wanted to change that value to

JavaScript
0.01 ether
JavaScript
uint256 fee = 0.00005 ether;

function setFee(uint256 _fee) public {
    fee = _fee;
}

But of course there are drawbacks for this. First of all, you have to foresee everything that you might need from the start. Second, you can’t add any new function or change the functionalities of existing functions. So this is more of an update than upgrade.

Contract Migration

This is basically deploying a completely new contract and transferring old data (storage, balances etc.) from previous contract to that newly deployed contract. If you have a large amount of data, you will have to split the migration into multiple transactions and with that, can incur high gas costs.This approach has also other drawbacks, of course the first one is transferring the data. Then you have to announce and convince people to use this new contract and say that this is the new address, then depending on the contract you have to contact exchanges or people who are using your contract and also let them know of your new contract address.

Data Separation

Using this approach we can separate our logic and the storage into different contracts. Clients are going to call the logic contract always, logic contract has the address of the storage contract so only logic contract interacts with the storage contract.In this approach, storage contract is always immutable. Only logic contract is replaced with a new implementation. So the storage (user balances etc.) stays the same but you can add new functionality or modify the existing ones without the need of a data migration. Also remember to change logic contract’s address in storage contract and to add an authorization layer so only logic contract can call the storage contract.

Proxy

First of all, if you don’t know what a Proxy is, without going into detail, Proxy is something that acts as gateway or a middleman, that sits between things like client/server, browser/server … and client/smart contract in our case.

In our case, basically we are not only deploying our contract but we also deploy a Proxy contract which will forward calls to the correct (latest version) implementation of the real contract, to do that it uses some low level functionality like delegating calls.

delegatecall executes other contract’s code inside the contract that called it. msg.value and msg.sender also doesn’t change in contract that the call is delegated.

Now we will call this Proxy contract instead of directly calling our contract.

See the basic code for this kind of operation:

JavaScript
assembly {
  let ptr := mload(0x40)

  // (1) copy incoming call data
  calldatacopy(ptr, 0, calldatasize)

  // (2) forward call to logic contract
  let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
  let size := returndatasize

  // (3) retrieve return data
  returndatacopy(ptr, 0, size)

  // (4) forward return data back to caller
  switch result
  case 0 { revert(ptr, size) }
  default { return(ptr, size) }
}

Of course this also comes with couple of problems, one of them is storage collisions but this is not something that we need to worry about if we’re using Openzeppelin, if not and you have your own Proxy contract then you can approach that problem in the same way that Openzeppelin does. Read more about storage collisions in Proxy contracts.

Another problem with Proxy approach is the function clashes. I will not go into detail yet you can read this story about function clashes in SC with Proxy approach for more details. Again Openzeppelin contracts handle function clashes for us, we’ll speak about how in a moment.

Now let’s see several different Proxy patterns.

Transparent Upgradeable Proxy

This is a proxy with a built in admin and upgrade interface. With this pattern, admin can only call admin functions in the Proxy and can never interact with the implementation contract.So if you are the admin of the contract and also want to interact with the implementation contract, that’s not possible. You have to interact with a different wallet.

JavaScript
modifier ifAdmin() {
    if (msg.sender == _getAdmin()) {
        _;
    } else {
        _fallback();
    }
}

This also avoids the function clashes because non admin users will always call _fallback and never be able to call Proxy Admin.

UUPS (Universal Upgradeable Proxy Standard)

This is very similar to Transparent Upgradeable Proxy but instead of having the upgrade logic in the Proxy, it’s in the implementation contract. UUPS proxies rely on an _authorizeUpgrade function to be overridden to include access restriction to the upgrade mechanism.

Benefits of UUPS over Transparent Proxy:

  • Since the upgrade functionalities are now in the implementation contract Solidity compiler can detect function clashes.
  • It’s gas efficient since there is no need for ifAdmin modifier anymore
  • Flexibility to remove upgradeability. Removing upgradeability is good but it may not be good if you forget to add upgrade logic to new implementation, resulting that upgradeability will be lost in the new contract.

Read more about Transparent vs UUPS Proxies

Diamond Proxy

Diamond Proxy allows us to delegate calls to more than one implementation contract, known as facets, similar to microservices. Function  signatures are mapped to facets.

JavaScript
mapping(bytes4 => address) facets;

Code of call delegation is very similar to the ones that UUPS and Transparent Proxies using but before delegating the call we need to find the correct facet address:

JavaScript
// Find facet for function that is called and execute the
// function if a facet is found and return any value.
fallback() external payable {
  // get facet from function selector
  address facet = selectorTofacet[msg.sig];
  require(facet != address(0));
  // Execute external function from facet using delegatecall and return any value.
  assembly {
    // copy function selector and any arguments
    calldatacopy(0, 0, calldatasize())
    // execute function call using the facet
    let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
    // get any return value
    returndatacopy(0, 0, returndatasize())
    // return any return value or error back to the caller
    switch result
      case 0 {revert(0, returndatasize())}
      default {return (0, returndatasize())}
  }
}

Benefits of Diamond Proxy:

  • All smart contracts have a 24kb size limit. That might be a limitation for large contracts, we can solve that by splitting functions to multiple facets.
  • Allows us to upgrade small parts of the contracts (a facet) without having to upgrade the whole implementation.
  • Instead of redeploying contracts each time, splitted code logics can be reused across different Diamonds.
  • Acts as an API Gateway and allows us to use functionality from a single address.

Read more about Diamond Proxy

Upgradeable ERC721 Contract with Hardhat

You can easily set up a Hardhat project from here: https://hardhat.org/hardhat-runner/docs/getting-started

First of all let’s create different versions of our NFT Contract. Create MyNFTV1.sol under the contracts directory.

JavaScript
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

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

contract MyNFTV1 is ERC721URIStorageUpgradeable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    function initialize() public initializer {
        __ERC721_init("MyNFT", "MYN");
    }

    function mintItem(string memory tokenURI) public payable returns (uint256) {
        require(
            msg.value >= 0.00001 ether,
            "You have to pay at least 0.00001 ether to mint."
        );

        _tokenIds.increment();

        uint256 newItemId = _tokenIds.current();
        _mint(msg.sender, newItemId);
        _setTokenURI(newItemId, tokenURI);

        return newItemId;
    }

    function latestTokenId() public view returns (uint256) {
        return _tokenIds.current();
    }
}

Now it’s time to mention that we do not have constructor  functions in our upgradeable contracts. Reason is, constructor functions runs immediately when the contract is deployed. We don’t want that, because we first deploy our contract and only then update the implementation address in the Proxy. So we need the initialize to run once we have upgraded the contract.

As you can see in V1 of our ERC721 contract we have a view function named latestTokenId and a mint function that takes a 0.00001 ether as fee.

Now let’s modify main function in the deploy.ts under the scripts directory and then run.

JavaScript
async function main() {
  const MyNFT = await ethers.getContractFactory('MyNFTV1');
  const myNFT = await upgrades.deployProxy(MyNFT);
  await myNFT.deployed();

  // try to mint the item with required fee
  try {
    await myNFT.mintItem('https://run.mocky.io/v3/17d6e506-17e3-4b53-a964-a7e0ed565ad0', { value: ethers.utils.parseEther('0.000001') });
  } catch (err: any) {
    console.log(err.reason);
  }

  // get the latest token id that is minted
  console.log(await myNFT.latestTokenId());
}

(Ignore the warnings I am using a newer version of Nodejs that Hardhat does not support yet) Transaction is reverted because we sent 0.000001 ether instead of 0.00001 ether. Now let’s try with the correct value now.

Great we got the correct token id now. Let’s create MyNFTV2.sol under the contracts directory.

JavaScript
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

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

contract MyNFTV2 is ERC721URIStorageUpgradeable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    function initialize() public initializer {
        __ERC721_init("MyNFT", "MYN");
    }

    function mintItem(string memory tokenURI) public payable returns (uint256) {
        require(
            msg.value >= 0.00005 ether,
            "You have to pay at least 0.00005 ether to mint."
        );

        _tokenIds.increment();

        uint256 newItemId = _tokenIds.current();
        _mint(msg.sender, newItemId);
        _setTokenURI(newItemId, tokenURI);

        return newItemId;
    }

    function latestTokenId() public view returns (uint256) {
        return _tokenIds.current();
    }

    function nextTokenId() public view returns (uint256) {
        uint256 current = _tokenIds.current();
        return current + 1;
    }
}

Difference of V2 than V1 is our fee now 0.0005 ether and we have a new view function named nextTokenId now let’s update our deploy script to deploy a fresh V1 contract, mints an NFT and then upgrade to V2 contract and mints another NFT and we need to see that our latest token id is 2 and be able to call nextTokenId

JavaScript
async function main() {
  // deploy a new contract
  const MyNFTV1 = await ethers.getContractFactory('MyNFTV1');
  const myNFTv1 = await upgrades.deployProxy(MyNFTV1);
  await myNFTv1.deployed();

  try {
    console.log(await myNFTv1.nextTokenId());
  } catch (_) {
    console.log(`nextTokenId function is not defined in version 1.`);
  }

  try {
    await myNFTv1.mintItem('https://run.mocky.io/v3/17d6e506-17e3-4b53-a964-a7e0ed565ad0', { value: ethers.utils.parseEther('0.00001') });
  } catch (err: any) {
    console.log(err.reason);
  }

  // upgrade the contract
  const MyNFT = await ethers.getContractFactory('MyNFTV2');
  const myNFT = await upgrades.upgradeProxy(myNFTv1.address, MyNFT);
  await myNFT.deployed();

  console.log(`MyNFT deployed to ${myNFT.address}`);

  // try to mint the item with required fee
  try {
    await myNFT.mintItem('https://run.mocky.io/v3/17d6e506-17e3-4b53-a964-a7e0ed565ad0', { value: ethers.utils.parseEther('0.00005') });
  } catch (err: any) {
    console.log(err.reason);
  }

  console.log(await myNFT.latestTokenId());
  console.log(await myNFT.nextTokenId());
}

Awesome, it works! You can see the codes for this in my GitHub https://github.com/dogukanakkaya/erc721-upgradeable it also contains the tests and config for Polygon Mumbai network so you can try this there also.

Related Articles

Secure web applications
Developers Security

Building Secure Web Applications in the Age of Cyber Threats: Essential Strategies

Build secure web applications that withstand evolving threats. Learn secure coding practic...

Read more
About us Blockchain Developers DevOps

Infrastructure, Blockchain, and Scalability with Erick Rettozi

In this article, Cyrex' Lead Backend Engineer Erick Retozzi offers expert advice on buildi...

Read more
APIs Developers

The Hidden Costs of DIY API Integration: When to Consider Professional Help

Avoid costly mistakes with professional API integration. Learn the hidden costs of DIY API...

Read more
AI Developers

LLMs, the gist

Unlock the magic of Large Language Models (LLMs) with our comprehensive guide. Discover ho...

Read more