Exploring ERC721I (and it’s children)
Ever since creating Message to Martians, I’ve been on a journey to create gas optimized contracts and find ways in order to save gas, many times inventing new methods of achieving existing things in the NFT space.
Initially, without gas optimization in mind, MTM Characters costed almost 1 million gas to mint because of the amount of state storage and reads that was needed.
Gas usage became more important as I was writing the code for MTM Characters and along the way I realized I had to completely redo my entire codebase and have gas optimization as one of my top priorities. Especially with the high cost of using the ETH network, gas optimizations is very important.
I packed my old code into an archived branch and started creating a new version of MTM Characters, starting by completely dissecting the ERC721 standard and rewriting it from scratch. I learned a lot from doing so. Thus, I created ERC721I and launched MTM Characters as the first implementation of ERC721I.
In the end, I was able to pack 2 burns, 1 mint, and some on-chain storage into 1/4 of the gas in the initial codebase by hyper-optimizing it, effectively reducing gas from 1 million to 250k and even lower using multi-mint methods. For reference, the two burns costed around 190–200k gas and the mint itself with on-chain storage only took around 50–60k gas!
Let’s take a look.
First off, I initialized the contract ERC721I and then defined some variables in a packed way: name, symbol, baseTokenURI, baseTokenURI_EXT and then created a constructor that sets the name and symbol of the contract.
Next, I created a public variable named totalSupply which is missing from ERC721 but is generally required in every contract that has a max token limit.
Then, I created two public mappings named ownerOf and balanceOf.
All these combined had replaced 3 public view functions totalSupply(), ownerOf(), and balanceOf(). By turning the mappings themselves into public variables, they are query-able now.
Two more mappings getApproved and isApprovedForAll is created. isApprovedForAll also replaces the function isApprovedForAll() by turning it into a public mapping as well.
Then, I defined some ERC721 standard-following events to be emitted from various functions.
Next, I created some functions that is required. Here, I created an internal function named _mint() which serves two core functionalities.
- Add a balanceOf to the address
- Set the ownerOf of the token ID to the address
Then, I emitted ERC721 standard event Transfer.
Fundamentally, that is really all that is needed for an NFT. A way to find out which token belongs to which address.
Some may debate that even balanceOf is a redundant storage and can be queried by looping the supply with a view function instead. I agree. But, as long as we are able to shift the standard to calling an array of tokens owned by address as a function argument instead of querying balanceOf for ownership amount. For now, I have added it for maximum interoperability.
The other core part of NFT logic is transferring. You have to be able to transfer your tokens to someone else. After all, NFTs are a medium of transfer and what good is digital ownership if you can’t transfer it?
Here, the main function we want is to
- Set a new owner in place of the old owner
- Remove balance from the sender
- Add balance to the receiver
I emit an ERC721 standard event Transfer after that.
The last part of the core NFT logic is that you are able to approve others to control your tokens. Here, there is an internal _approve() and internal _setApprovalForAll() functions that set the mappings shown above to indicate this.
Also, I emit ERC721 standard events.
Now, more functionality. _isApprovedOrOwner() just returns if the address (generally the spender) is the owner, is approved, or is approved for all. It is generally used for require statements to prevent unauthorized access. _exists() makes sure the token doesn’t belong to address(0x0).
Next a few public functions for approvals. They approve your tokens to other operators to control, with the necessary checks put in place.
Then, we have transfer functions. transferFrom() checks _isApprovedOrOwner() first and then allows you to transfer. safeTransferFrom() inherits transferFrom method but also makes sure that if the to_ address is a contract, call it and make sure that it has the function selector of ERC721Receivable.
Generally, I think this check is useless and I only had to add it in to follow the ERC721 standard. Having the function selector doesn’t really mean anything because it doesn’t really prove that you have the necessary logic put in place to handle ERC721. Well, whatever.
Then, following the standard ERC721 transfer logic, I added built-in multi-transfers into the contract so that people can multi-transfer multiple tokens at once for maximum gas-efficiency and not having to rely on a separate contract to do that for you. Nice!
For reference, you can save up to something like 70% gas on multi-transfers compared to single transfers.
Just some other standard stuff that returns static bytes4 that is used to represent some function selectors. This is a fun way to bypass what the function actually wants by just returning the static bytes4 instead of encoding the function selector lol
Now we have tokenURI which reads from the two State Storage above baseTokenURI and baseTokenURI_EXT. In the middle, it encodes the UINT256 of tokenId_ passed into it into a string and returns a concatenated string of [baseTokenURI, tokenId_, baseTokenURI_EXT] which generally returns the link to the metadata. Nice!
There is also internal functions for you to inherit Ownable and set onlyOwner functions in to set the Token URI.
This is internal purposefully because ERC721I does not have Ownable functions and setting the base token URIs should be an owner-only thing.
Then, we have this nice view-loop which emulates the ERC721Enumerable standard implementation which returns an array of all the tokens owned by the owner address. This is the main thing that reduces gas on ERC721 vs ERC721Enumerable contracts because the ERC721Enumerable standard stores these into storage and updates it on all transfers which results in a lot more State Storage writes which is the leading cause of gas consumption.
Lastly, there is a tokenOfOwnerByIndex() function which is basically just returning the index of the walletOfOwner() function. Funny enough I have not seen a contract that actually uses this besides building the walletOfOwner() function in ERC721Enumerable applications.
…. And there you have it! A fully working, fully compatible ERC721 implementation that only took 190 lines of code. Hooray!
Some closing notes:
Since building the first contract of ERC721I in my own libraries, I have been creating add-ons and additional contracts for my own usage within the ERC721I ecosystem. Seems I will release more cool things in the future!
Feel free to contact me if you would like to suggest some changes or find anything wrong with the code. After all, writing a new library is not the easiest thing to do! :]
You can find the full code in the repository below. Feel free to fork!