This tutorial is part 3 of a series where I am teaching how to build smart contracts for NFT collections using the ERC-721 standard with the Solidity language. If you want, check out Part 1 and Part 2.
We had theoretical introductions, the main construction, and the mandatory structure of the 721 specification, and in part 2 we even built a mint function following suggestions from the specification such as using JSON MetaData URIs. In this third part, we will bring some more common, but optional, functions that are worth studying and that are also suggested in the specification.
Another extremely relevant function for some projects is burning. Burning is the act of destroying (burning, in English) a token for whatever reason. In our contract framework, you can implement a burn function as follows.
I started by taking the current owner’s address and checking two things against it:
-> First, it cannot be zero, otherwise, it means the token has not been mined yet;
-> Second, he must be the burn requester or have permitted the requester (approve or approveForAll);
After these two verifications, I reduce the balance of the token’s owner, destroy its property record by setting the address to zero and finally clean its URI to save space in the block. I also take the opportunity to clear the delegation/approval of control, if any, and issue the events required by the ERC721 standard, notifying that there has been a transfer and a change of authorization, both linked to the zero address wallet.
Other functions extremely useful and even suggested in the specification are those related to the Enumerable extension. But what would an NFT contract with enumeration be? It is a contract with functions that allow access to owners’ NFTs by their index instead of tokenId, facilitating the construction of dapps, marketplaces, etc. Without this type of functionality, you have to know the id of each token to access it, which is often unfeasible if they are hashes.
Below is the ERC721Enumerable interface as suggested in the specification:
It provides the following functions:
-> totalSupply: total amount of NFTs in the contract (owner != 0);
-> tokenByIndex: accesses a token by its index, among all of the collection;
-> tokenOfOwnerByIndex: accesses an owner’s token by its index;
To implement this extension, copy the interface above and place it in our MyNFT.sol, along with the other interfaces. Then modify the supportsInterface function to include it.
And now, let’s implement the three new functions, starting with some new state variables that will be needed.
These variables control the state of the indices, being them.
-> _ownedTokens: mapping that lists all of its indices and tokens for each owner;
-> _ownedTokensIndex: mapping that lists its owner index for each token;
-> _allTokens: array containing all existing tokens;
-> _allTokensIndex: mapping that lists its global index for each token;
Now we can use these variables in the implementation of interface functions.
The three functions are quite simple but serve their purpose of providing token enumeration mechanisms.
The first one, totalSupply, counts how many tokens we have in the _allTokens array.
The second, tokenByIndex, returns the tokenId given a position in the global array of tokens, but not before checking if that index exists.
And the third and last one, tokenOwnerByIndex, does the same as the previous one, but only within the scope of tokens of a specific owner, which also has the index validated before continuing.
Note that these functions depend on the control variables being properly updated, which requires us to update the existing mint and burn functions in our contract.
In the mint function, I added four new lines just before issuing the transfer event in order to ensure that each new minted token receives an index at the end, both in the global array and in the virtual array of each owner. First, we add the new id token to the global array. Then, we add the position of the new token in the mapping of global indexes, using the number of tokens to know the last existing position. These first two statements update the state variables linked to the global token enumeration.
Next, we add a new id to the mapping that relates ids to indices, using the owner’s token balance to always assign the last position as an index. And finally, we added a new id in the mapping that lists the positions of each owner’s tokens. These last two statements update the state variables linked to the enumeration of tokens for each owner.
But what about the burn?
Burn with Enumerable
Here the challenge is a little harder, but not much. This is because when we destroy an NFT, it can leave “holes” in the organization of the indexes when the destroyed NFT was not at the end of the global list or even the virtual list of one of the owners. In this case, we have three approaches that we can do:
-> nothing: i.e. leave the holes. Thus, it may be that when accessing an NFT, global or owner index, the id of an already destroyed NFT comes up, which does not generate additional gas costs but ends up consuming unnecessary space and impairs the user experience;
-> reorder: that is, reorganize all items in the global and virtual arrays by the owner so that the hole is filled without losing the original order of the tokens (issue order). This approach has a linear gas cost (O(n)) and the best user experience, in addition to not wasting disk space;
-> fill the hole: rearranging just one element in the array to fill the hole left by the burn of another. This approach is a compromise because it has a stable gas cost (O(1)), it does not waste disk space, but it can cause some strangeness in the user because the order of the NFTs will not respect the issuing order after he makes one or more burns.
Given these characteristics, I will do the third option here, but feel free to modify its implementation according to your preferences and goals with this type of contract.
I’ve included some comments as it’s questionable readability code due to the relative complexity of the logic. Still, a quick summary would be that the strategy consists of making a copy of the last element of the global array to the position to be deleted, overwriting it, and then removing the duplicate element that was at the end. This is done both for the global array and for the virtual array of the owner that had the token burned.
The practical result of this logic is that the last token is moved to the position that the burn made vacant, to fill it.
With these new functions, your NFT contract is certainly more complete, and below are some suggestions of other functions that you might be interested in creating:
-> withdraw: function to make withdrawals, in the case of payable NFTs;
-> pause: function to pause new mints, in order to safely allow some maintenance;
Another tip is for you to host both the metadata and the media of your NFTs on IPFS, I talk about this in the video below.
And with that, we have completed this stage in the development of our NFT contract. We managed to cover the main part in these three parts, and I hope I have brought you a better understanding of this type of implementation, which is one of the most requested by web3 professionals today.
Want to learn an even more professional way to implement? Check it out in this tutorial with HardHat and OpenZeppelin.
Want to learn a way with fewer gas fees in minting? Check it out in this ERC-721a (Azuki) tutorial.
See you next time!
*The content of this article is the author’s responsibility and does not necessarily reflect the opinion of iMasters.