paint-brush
Understanding Token Standards in Ethereum Part-II (ERC721)by@zartaj
2,489 reads
2,489 reads

Understanding Token Standards in Ethereum Part-II (ERC721)

by Md Zartaj AfserMarch 1st, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

ERC-721 standard was proposed for NFTs(Non-Fungible Tokens) NFT refers to the tokens that are unique, which means every token inside the smart contract, will be different from others. It can be used to identify unique things, for example, some kind of image, lottery tickets, collectibles, paintings, or even music, etc.
featured image - Understanding Token Standards in Ethereum Part-II (ERC721)
Md Zartaj Afser HackerNoon profile picture


ERC-721 standard was proposed for NFTs(Non-Fungible Tokens). NFT refers to the tokens that are unique, which means every token inside the smart contract, will be different from others. It can be used to identify unique things, for example, some kind of image, lottery tickets, collectibles, paintings, or even music, etc. To remind you, ERC-20 was for the fungible tokens, if you haven’t read the article on ERC-20 then consider reading it first. The best real-world project for ERC-721 example is Cryptokitties. It is a game on top of the blockchain, check out this amazing game for knowledge with fun.


But how does this work? How are these images, music, and paintings stored in smart contracts?


Well, these things are not stored inside the contract instead the contract just points to an external asset. There is a number, tokenId showing the ownership. The main asset is the image or some other data that is attached to the tokenId through a URI. Let’s dive a little bit more.


We assign a URI, which is a JSON text, to the token ID. And that JSON text contains the details and path to the image or any other asset. And the user only owns the tokenid. For example, my address has the balance of tokenid 4, now the image or any kind of data attached to that tokenid will be mine. I own that data because I own the tokenid.


Now we have to store these two things i.e. Image and the JSON text(URI) somewhere so that any NFT marketplace can fetch the image and display it on their website. You might be thinking why do we need metadata URI, why not directly assign the Image URI to the token id? Well, you might remember why we needed the ERC standards in the first place, yes, so that it becomes easy for the client-side applications to connect with the contracts. Similarly, the metadata we write for NFTs should be in a proper format recommended by the NFT marketplace so that they can easily fetch the data from that JSON file and display the data on their site.


Let’s see the storage thing now.

Storing the data

There are two ways to store the image and URI. We can either host it on-chain(inside the blockchain) or off-chain (outside the blockchain).


The on-chain data is stored in the blockchain but it takes up a lot of space which is not affordable. Just think of paying thousands of dollars at the time of deploying your contract. Doesn’t sound good. Apart from this, the blockchain provides limited storage too, you can’t store large files in it.


So we have the option to host the data outside the blockchain. Through our website using AWS or any other cloud service. But this kills the main idea of a blockchain i.e. decentralization. What if the server crashes or someone hacks it? As there would be a single server hosting all the data. So we need something else which is robust to attacks and also decentralized and that something is IPFS(Inter Planetary File System) which is a distributed storage system. IPFS has multiple nodes similar to the idea of blockchain that stores the data. But you don’t have to pay any fees. We can host our images and also the JSON file(URI) on IPFS and doing so will give a unique CID(random gibberish) that directly points to the data and we assign a particular CID to the particular token Id. We will talk more about the technical part later first let’s understand the interface for the ERC-721 standard.

ERC-721 INTERFACE

Let’s see what the ERC-721 interface looks like.

This standard has the following functionalities :

  1. Transferring tokens from one account to another.

  2. Getting the current token balance of an account.

  3. Getting the owner of a specific token.

  4. The total supply of the token available on the network.

  5. Besides these, it also has some other functionalities like approving that an amount of token from an account can be moved by a third party account.


Now let’s take a look at the interface for ERC-721.

pragma solidity ^0.4.20;

interface ERC721 {
    
 event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);

 event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);

    
 event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);

 function balanceOf(address _owner) external view returns (uint256);
    
 function ownerOf(uint256 _tokenId) external view returns (address);
    
 function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;

 function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;

 function transferFrom(address _from, address _to, uint256 _tokenId) external payable; 
    
 function approve(address _approved, uint256 _tokenId) external payable;

 function setApprovalForAll(address _operator, bool _approved) external;

 function getApproved(uint256 _tokenId) external view returns (address);
   
 function isApprovedForAll(address _owner, address _operator) external view returns (bool);
}

Break

Breakdown

  1. balanceOf Returns the number of tokens in the owner’s account. How do we keep the track of balances? Well, it’s always the same, we define a mapping that tracks the total number of tokens in a wallet. mapping(address => uint) public balances; Remember, it just sums up the total number of tokens and returns the same, this mapping doesn’t know about the owners of the token as it just tells the number of token ids an address has.
function balanceOf(address _owner) external view returns (uint256);


2. ownerOf Finds the owner of an NFT. Now, this function tells us about the owner of a particular NFT. We keep this data similar to the above. mapping( uint => address) public balances; NFTs assigned to zero addresses are considered invalid, and queries about them should throw an error.

function ownerOf(uint256 _tokenId) external view returns (address);


3. safeTransferFrom Transfers the ownership of an NFT from one address to another. It should just set the owner of a tokenId to the new address and also update the balances mapping. It should throw an error in the following cases:


  • msg.sender is the current owner, an authorized operator, or the approved address for this NFT
  • _from is not the current owner
  • __to_ is the zero address.
  • __tokenId_ is not a valid NFT


When the transfer is complete, this function checks if __to_ is a smart contract (code size > 0). If so, it calls the onERC721Received function on __to_ and throws if the return value is not equal to this: bytes4(keccak256("onERC721Received(address, address,uint256, bytes)")).


What was it? This is why the function is called safe transfer, we will talk more about it later.


function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data)      external payable;


4. safeTransferFrom: This works identically to the above function with an extra data parameter, except this function just sets data to “ ”.


function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;



How are these two functions have the same name?

Fun Fact: Unlike Javascript, Solidity does support Function Overloading. means we can define two functions with the same names, the only condition is the parameters should differ, making a difference in the function signature. Don’t know what function signatures are? Here you go. Link to answer


5. transferFrom transfers ownership of an NFT. Works the same as the safeTransferFrom function, the only difference it doesn’t call the safety call(onERC721Received) on the recipient. Thus, creating a risk of losing the NFT.


function transferFrom(address _from, address _to, uint256 _tokenId) external payable;



6. approve the same concept as the ERC20 approval function but with more functionality and logic. For this function to work we define a nested mapping: mapping(uint =>address) internal allowance; In this mapping, the uint refers to the token id and the address which is approved to perform operations on that particular tokenId. This approve function should check that the msg.sender is the current owner or an operator for the owner of the token. If this check passes then only the mapping should be updated. What is the operator here? See the next function.

function approve(address _approved, uint256 _tokenId) external payable;



7. setApprovalForAll works like the previous approval function but instead of approving an address for a single token Id, this function should approve and address to handle all of the tokens owned by a particular address. What does it mean? It means you can make an address operator for your NFTs. That address will be the owner of your NFTs until you revoke the ownership. This time we again use the mapping: mapping(address => mapping(address=>bool))internal operator; The first address sets the boolean true or false for the second address to approve or revoke respectively.


function setApprovalForAll(address _operator, bool _approved) external;


8. getApproved is similar to the allowance function in the ERC-20 interface. Returns the approved address for a single NFT. It checks the mapping we updated at the approve function above.


function getApproved(uint256 _tokenId) external view returns (address);


9. isApprovedForAll similar to the above function. It queries if an address is an authorized operator for another address. Instead of returning the approved address of a particular tokenId, this function should return the address of the operator for the given address which we updated at the setApprovalForAll function

function isApprovedForAll(address _owner, address _operator) external view returns (bool);

Events for ERC-721

Transfer: emits when ownership of any NFT changes by any mechanism. This event emits when NFTs are created (from == 0) and destroyed(to == 0). Exception: During contract creation, any number of NFTs may be created and assigned without emitting Transfer. At the time of any transfer, the approved address for that NFT (if any) is reset to none.


event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);


Approval emits when the approved address for an NFT is changed or reaffirmed. The zero address indicates there is no approved address. When a Transfer event emits, this also indicates that the approved address for that NFT (if any) is reset to none.


event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);


ApproveForAll emits when an operator is enabled or disabled for an owner. The operator can manage all NFTs of the owner.


event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);



This was the interface for ERC 721 contracts. Now let’s learn more about its implementations and rules.

ERC721TokenReceiver


Before implementation, there is one more thing. Remember we talked about a function called onERC721Received when we talked about transferring tokens? Let’s talk about that now. If the receiver of an ERC-721 token is a contract then it needs to be functional with the ERC-721 tokens. Because what if there is no functionality to interact with the tokens in that receiver contract? The tokens will get locked in that contract forever. Nobody can take those tokens out to any address as there is no functionality. To overcome this vulnerability, the receiver of the ERC-721 token should implement the receiver Interface, if not then non of the contracts can send any ERC-721 token to that contract. Let’s look at the receiver interface.


This interface contains only one function to implement and that is onERC721Received, this function should handle the receipt of an NFT. The ERC-721 smart contract calls this function on the recipient after a transfer. This function MAY throw to revert and reject the transfer. Returns other than the magic value MUST result in the transaction being reverted.


interface ERC721TokenReceiver {
function onERC721Received(address _operator,address _from,uint256 _tokenId,bytes _data) external returns(bytes4);
}


Here you might be thinking that how would the token contract know if the receiver has implemented the ERC721TokenReceiver interface or not? So before sending the tokens you have to first check this part. For this let’s look at the openzeppelin implementation of ERC-721. Here is the private function which you should call inside the safeTransfer() before sending the tokens. If this returns true then only the transaction should occur.



Well, this does not guarantee the functionality of getting the NFTs out of the receiver contract because it is only checking the implementation of the receiver function but not the functionality of transferring the NFTs out of the contract. So what is the significance of it? This method can tell us that the author of the receiver contract was at least aware of this method, which means they must have implemented the functionality to transfer the NFTs out of the contract. And of course, nobody would want their tokens to get stuck inside a contract.

ERC721Metadata and ERC721Enumerable

The above interfaces were mandatory for an ERC-721 token contract. Now, some interfaces are theoretically optional but make the contract more clear and more usable.


These are ERC721Metadata and ERC721Enumerable. Let’s catch them one by one.


  1. ERC721 Metadata: Metadata extension is used to mainly interrogate the contract for the token name. Now let’s look at the Metadata interface. This is quite simple to understand.
interface ERC721Metadata/* is ERC721 */ {
/// @notice A descriptive name for a collection of NFTs in this
/// contract
function name()external view returns (string _name);
/// @notice An abbreviated name for NFTs in this contract
function symbol()external view returns (string _symbol);
/// @notice A distinct Uniform Resource Identifier (URI) for a given
 /// asset.
/// @dev Throws if `_tokenId` is not a valid NFT. URIs are defined
/// in RFC3986. The URI may point to a JSON file that conforms to 
/// the "ERC721Metadata JSON Schema".
function tokenURI(uint256 _tokenId)external view returns (string);


  1. ERC721Enumerable- This just gives the data about the NFTs.

    interface ERC721Enumerable/* is ERC721 */ {
    /// @notice Count NFTs tracked by this contract
    /// @return A count of valid NFTs tracked by this contract, where
    /// each one of them has an assigned and queryable owner not equal 
    /// to the zero address
    function totalSupply()external view returns (uint256);
    /// @notice Enumerate valid NFTs
    /// @dev Throws if `_index` >= `totalSupply()`.
    /// @param _index A counter less than `totalSupply()`
    /// @return The token identifier for the `_index`th NFT,
    ///  (sort order not specified)
    function tokenByIndex(uint256 _index)external view returns (uint256);
    /// @notice Enumerate NFTs assigned to an owner
    /// @dev Throws if `_index` >= `balanceOf(_owner)` or if
    ///  `_owner` is the zero address, representing invalid NFTs.
    /// @param _owner An address where we are interested in NFTs owned /// by them
    /// @param _index A counter less than `balanceOf(_owner)`
    /// @return The token identifier for the `_index`th NFT assigned to /// `_owner`,
    ///   (sort order not specified)
    function tokenOfOwnerByIndex(address _owner,uint256 _index)external view returns(uint256);
    

ERC165

Now, how would we know whether an interface has been implemented by a contract or not? For this, we need to go off topic i.e. ERC-165, which has a function:

function supportsInterface(bytes4 interfaceID) external view returns (bool);


This function takes the interfaceId you want to check as an argument and matches it to the interfaceId you want to check with. I will tell you what an interfaceId is, bear with me. First look at the implementation of this function by openzeppelin.

Let’s understand more about interfaceId in this function type(IERC721).interfaceId ;



interfaceID is achieved by two main operations.

1. keccak 256 hash

2. XOR operation


keccak256 is an algorithm that takes an input and spits out some random string in bytes. Now let’s find out the keccak hash for this function.

function supportsInterface(bytes4 interfaceID) external view returns (bool);

The syntax looks something like ṭhis.

bytes4(keccak256(’supportsInterface(bytes4)’);



Now we have the keccak hash for this function. Remember, you don’t have to pass the whole function inside the parameter for keccak256 but only the signature. The signature means only the name and parameter type, not even the name of the parameter is included. After getting the keccak256 hash of all the functions we perform the XOR operation among them and the result we get is the interfaceId. Let’s see how. XOR operations take inputs and give some outputs after comparing them. It outputs true if one, and only one, of the inputs is true. If both inputs are false or both are true, the output comes out to be false.


You don’t need to understand the maths behind XOR. Just remember, you take the keccak256 hash of every function and pass them to the XOR gate and the value you get is the interfaceID. So the main idea is to take the keccak hash of the functions and get their XOR output. that output is the interfaceId and after that, you can check for a contract whether it has the same interfaceID, If yes that means the contract is implementing the desired interface.

Don’t worry solidity has made this easy for you. Let’s learn by taking the Interface for ERC721Metadata as an example.


interface ERC721Metadata/* is ERC721 */ {
function name()external view returns (string _name);
function symbol()external view returns (string _symbol);
function tokenURI(uint256 _tokenId)external view returns (string);
}


Here we have three functions. So I have written this piece of code to make you understand. Notice here the caret symbol^. This symbol is representing the XOR operation between two values.

// SPDX-License-Identifier: MIT
pragma solidity   ^0.8.16 ;
interface  metadata {
function name()external view returns (string memory _name );
function symbol()external view returns (string memory _symbol);
function tokenURI(uint256 _tokenId)external view returns (string memory a);
}

contract  {
//old and lengthy way by applying the keccak256 algorithm and performing XOR.
   function getHashOldWay ()public pure  returns(bytes4){
return  bytes4(keccak256('name()')) ^  bytes4(keccak256('symbol()'))^ bytes4(keccak256('tokenURI(uint256)')); 
   }
//Improved way
   function getHashNewWay ()public pure  returns(bytes4){
     metadata a;
     return a.name.selector ^  a.symbol.selector ^ a.tokenURI.selector; 
   }
//this is the latest simplest way to get the interfaceId
    function getHashLatestWay ()public pure  returns(bytes4){
    return  type(metadata).interfaceId;
    }
 }


Ohh we went too much into the ERC165 that we forgot about the ERC721 let’s get back to it.

How does opensea(or any other marketplace) fetch data from the smart contract?


This is the correct time to understand the token URIs which help the various NFT marketplace to identify the data you have assigned with a particular tokenId. As Opensea is the most famous marketplace, we will take it as an example. Opensea expects your ERC721 contract to return a tokenURI by calling a tokenURI() function. Let’s look at the openzeppelin contract for this.

TokenURIs can be of two types.


1. Same base URI for all tokens, varying by the tokenId.

This way we assign a base URI for all tokens and then concatenate the tokenId to it, getting the metadata URI. This is only possible when you have assigned your collection in a way where the link is the same for every metadata JSON file, the only difference would be the tokenId. For example,

https://gateway.pinata.cloud/ipfs/QmYLwrqMmzC3k4eZu7qJ4MZJ4SNYMgqbRJFLkyiPtUBZUP/This link points to the folder, meaning that this is the baseURI.

To get a single metadata we need to go to https://gateway.pinata.cloud/ipfs/QmYLwrqMmzC3k4eZu7qJ4MZJ4SNYMgqbRJFLkyiPtUBZUP/1.json

And now you have to return the tokenURI from the contract in a way that it directly goes to the metadata of that particular token, and the marketplace can fetch it. We do that by concatenating the baseURI with the tokenId, and abi encoding it. Look at this function below.


function tokenURI(uint tokenId) override public view returns(string memory) {
  return (string(abi.encodePacked(
   "https://gateway.pinata.cloud/ipfs/QmYLwrqMmzC3k4eZu7qJ4MZJ4SNYMgqbRJFLkyiPtUBZUP/",Strings.toString(tokenId),".json"))
  );
}

This is the function that the marketplace will call to get the URI and display all the data we have provided in the JSON file.



2. different URIs for each token.

This is another way where we can assign URIs that are located in different locations. And the IPFS link for an individual file looks like this.https://ipfs.filebase.io/ipfs/Qma65D75em77UTgP5TYXT4ZK5Scwv9ZaNyJPdX9DNCXRWc This way you can’t just concatenate the tokenId with baseURI to get this link, instead, this link should be directly attached to the tokenId on-chain. And for that look at the above ERC721URIStorage contract where we are defining a private mapping to assign a URI to a particular tokenId. And later also defining a function that populates the mapping with a given URI. And the tokenURI function overrides the parent function by adding a new functionality of returning the mapped URI.

One more thing to notice is the format of JSON schema. The JSON should contain the data in a standardized way so that the marketplace can fetch the desired data and display the same.


The standard JSON schema for ERC721.

{
    "title": "Asset Metadata",
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "description": "Identifies the asset to which this NFT represents"
        },
        "description": {
            "type": "string",
            "description": "Describes the asset to which this NFT represents"
        },
        "image": {
            "type": "string",
            "description": "A URI pointing to a resource with mime type image/* representing the asset to which this NFT represents. Consider making any images at a width between 320 and 1080 pixels and aspect ratio between 1.91:1 and 4:5 inclusive."
        }
    }
}


So these were the things you must know if you are interested in ERC-721 (NFT)smart contracts development.



Problems:

There are several problems with scalability in the ERC721 standard. The problem comes when transferring the tokens because the ERC-721 allows you to transfer one token at a time. This means if you want to send someone 5 NFTs then you would need to perform 5 transactions. This is quite clear to you how much space it would take in the blockchain. This is the reason why in the bull runs the network faces problems, as there is too much rush in the network, and the gas fees increase rapidly.


ERC-1155 solves these problems. How? Let’s talk about this in a separate post. If you have understood the ERC721 standard completely then ERC1155 will not be much difficult to understand.


Also published here.