A Look at the Functionality Behind Non-Fungible Tokens

A Look at the Functionality behind non-fungible Tokens

By Phil Mesnier, OCI Principal Engineer, Partner and Blockchain Practice Lead

May 2021


Introduction

Non-fungible tokens (NFTs) have started to appear in popular media as the new hot thing[1][5], but work on a standard definition of them is several years old. This article focuses on the smart contracts behind NFTs, their attributes, and their actions. We focus on the Ethereum blockchain and its standard for NFTs, referred to as ERC-721. After examining the standard with its extensions, we take a look at how the standard is extended to create custom token types and what else client applications need to be able to do to support NFTs.

So what are NFTs?

An NFT is an abstract representation of some asset, digital or real, that has value. Being non-fungible means a given token represents a specific asset and therefore cannot be substituted for another token.

This is different from fungible tokens, such as Bitcoin or dollar bills, which can be freely swapped without affecting the underlying value. A dollar is a dollar is a dollar, as the old saying goes. An NFT cannot be freely swapped because it represents the ownership of something unique, such as a particular music clip or video.

NFTs can also represent tangible assets, such as real estate, artwork, or automobiles. The NFT has a particular owner, and ownership may be transferred to another via a contract. In this way, NFTs and the assets they represent may be traded on open marketplaces such as OpenSea[7].

Common use cases for NFTs include tracking ownership of popular GIFs, works of visual or sonic art, and also tangible items, such as sports trading cards or any particular goods for which there is sufficient value and scarcity to warrant tracking their ownership on the blockchain.

A little context first

If you are familiar with the concept of blockchain and smart contracts[6], you may skip ahead to the next section.

NFTs are useful because they are provably unique, and ownership can be proven as well. The proof of each of these traits depends on the blockchain.

Blockchains are fundamentally ledgers containing a history of transactions. The ledger is immutable due to the cryptography employed to construct the chain.

The transactions stored in the blockchain result from the execution of contracts. Contracts may be simple, such as the exchange of value logged on the Bitcoin blockchain, or they can be complex. Complex transactions are the result of executing smart contracts, which can be compared to applications, but run within the blockchain.

What goes into making an NFT?

An NFT is a realization of a smart contract executed on a blockchain. This smart contract brings together a collection of asset attributes along with an owner identifier.

A standard describing NFT smart contract requirements for use in the Ethereum space is ERC-721[3]. This standard is expressed as a smart contact base class from which an NFT contract may be derived to simply focus on those aspects that are particular to the new instance.

A full listing of the ERC721 contract definition is available from OpenZeppelin[4]. This source provides the basis for the code examples in this document.

The language of the following code examples is Solidity, the default language for Ethereum smart contracts. For more information on the Solidity language, see Introduction to Smart Contracts[8].

Now we will take a look at what the ERC-721 standard implementation provides in terms of data.

contract ERC721 is Context, ERC165, IERC721, IERC721Metadata {
   using Address for address;
   using Strings for uint256;
 
   // Token name
   string private _name;
 
   // Token symbol
   string private _symbol;
 
   // Mapping from token ID to owner address
   mapping (uint256 => address) private _owners;
 
   // Mapping owner address to token count
   mapping (address => uint256) private _balances;
 
   // Mapping from token ID to approved address
   mapping (uint256 => address) private _tokenApprovals;
 
   // Mapping from owner to operator approvals
   mapping (address => mapping (address => bool)) private _operatorApprovals;
 

The first thing we see is that this is a contract definition class, implementing a group of interfaces.

We are going to ignore the Context and ERC-165 bases, but IERC721 and IERC721Metadata are a couple of interfaces that define all the necessary operations for the NFT standard. Then we have a group of internal attributes that are used to manage the token state.

Next let’s take a look at the operations from those interfaces.

constructor (string memory name_, string memory symbol_) {}
function balanceOf(address owner) public view virtual override returns (uint256) {}
function ownerOf(uint256 tokenId) public view virtual override returns (address) {}
function name() public view virtual override returns (string memory) {}
function symbol() public view virtual override returns (string memory) {}
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {}
 
function _baseURI() internal view virtual returns (string memory) {}

This first group of functions are simple data accessors. The last one shown above, _baseURI(), is actually an internal function. It is intended to be overridden by the specialized NFT contract, since the tokenURI definition is so variable. The default implementation returns an empty string.

function approve(address to, uint256 tokenId) public virtual override {}
function getApproved(uint256 tokenId) public view virtual override returns (address) {}
function setApprovalForAll(address operator, bool approved) public virtual override {}
function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) {}

This group of functions is all about approvals. Owners of NFTs can delegate authority for action on the token to approved operators. These operators are then allowed to perform certain actions on the token.

function transferFrom(address from, address to, uint256 tokenId) external;
function safeTransferFrom(address from, address to, uint256 tokenId) external;
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;

This last group of functions from the ERC-721 standard are involved with the transfer of token ownership to a new entity.

  • The from argument must be the token owner or an allowed operator.
  • The to address must not be the owner and must be a valid address.
  • The tokenId argument is the asset being transferred.
  • The safe transfers impose a requirement on the to address that it must implement the IERC721Receiver interface.
interface IERC721Receiver {
   function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external returns (bytes4);
}

This is called by the safeTransferFrom (and internal _safeMint) functions to explicitly inform the receiver that it is becoming owner of an NFT.

In this call:

  • The operator is the address of the transaction invoker, which may be different from the from address.
  • The from address is that of the current owner of the token involved.
  • TokenId is the identity of the token being transferred.
  • data is optional call data that may be used to pass specialized context for the transfer along with the token.

Getting back to the ERC721 that we have looked at so far, you might notice no externally accessible minting functions. That is because the minting functions are all internal. They are intended to be invoked only by your derived contracts, ideally after your custom minter or creator function has vetted all the input and determined that the new token is indeed suitable for minting. Then a call to _mint or better, to _safeMint, is invoked to create a new association between the new tokenId and the address to which the token is granted.

function _safeMint(address to, uint256 tokenId, bytes memory _data) internal virtual {}
function _safeMint(address to, uint256 tokenId) internal virtual {}
function _mint(address to, uint256 tokenId) internal virtual {}
function _beforeTokenTransfer(address from, address to, uint256 tokenId) internal virtual { }
function _exists(uint256 tokenId) internal view virtual returns (bool) {}
function _burn(uint256 tokenId) internal view virtual {}

These are, to coin a phrase, the money functions.

As with the transfer variants shown above, the safe versions require the to address to be an entity that implements the IERC721Receiver interface so that it can be notified of its new NFT.

The _mint function takes a tokenId that is created by a particular NFT implementation code; it is your responsibility to create that. We will see an example of that shortly.

_Exists() and _beforeTokenTransfer() are utility functions used to validate inputs in token-ownership related functions. The _exists() function verifies the existence of the identified token, and _beforeTokenTransfer() is called before executing a transfer, burn, or mint action.

The last of the significant NFT functionality is the burn function. Burn is the inverse of mint. It may be used to destroy tokens. Just like mint, burn is virtual, allowing for specialization in order to destroy other token-specific resources.

The ERC-721 standard also includes optional interfaces for less common token operations.

  • ERC721Burnable.sol provides a public interface to the token destruction functionality. The burn function validates that the caller is allowed to destroy the token and then calls the _burn internal function described above.
abstract contract ERC721Burnable is Context, ERC721 {
   function burn(uint256 tokenId) public virtual {}
}
 
  • ERC721Enumerable.sol enables the mapping of tokenIds to owners and provides public functions to search for tokens and owners.
abstract contract ERC721Enumerable is ERC721, IERC721Enumerable {
   // Mapping from owner to list of owned token IDs
   mapping(address => mapping(uint256 => uint256)) private _ownedTokens;
 
   // Mapping from token ID to index of the owner tokens list
   mapping(uint256 => uint256) private _ownedTokensIndex;
 
   // Array with all token ids, used for enumeration
   uint256[] private _allTokens;
 
   // Mapping from token id to position in the allTokens array
   mapping(uint256 => uint256) private _allTokensIndex;
 
   function tokenOfOwnerByIndex(address owner, uint256 index) public view virtual override returns (uint256) {}
 
   function totalSupply() public view virtual override returns (uint256) {}
 
   function tokenByIndex(uint256 index) public view virtual override returns (uint256) {}
  • Internally, the ERC721Enumerable contract provides an overridden _beforeTokenTransfer, which is called whenever a token is minted, burnt, or transferred. In that function, the various maps of tokenIds and owners are updated to ensure consistency.
  • ERC721Pausable.sol introduces the security/pausable.sol functionality, which adds a new state to the token: paused. A specialized _beforeTokenTransfer function prevents transactions when the token is in the paused state.
  • ERC721URIStorage.sol defines the necessary functionality to support off-chain storage of NFT artifacts. This includes adding an internal function, _setTokenURI and a specialized _burn function to clean up the URI resources when the token is destroyed.

A Simple Example NFT

Imagine a graphic design firm that specializes in creating corporate logos. They want to use NFTs to protect their intellectual property. This LogoCoin token captures a few attributes of a logo instance.

This example draws on another example available in “How To Create NFTs With Solidity”[1].

pragma solidity ^0.8.0;
 
import "@openzeppelin/contracts/token/ERC721/extension/ERC721URIStorage.sol";
 
contract LogoCoin is ERC721URIStorage {
 
   uint256 public tokenCounter;
   enum palette {grayscale, twocolor, fullcolor}
   enum scale {icon, small, medium, large}
 
   constructor ()
   ERC721("LogoCoin","LOGO")
   {
       tokenCounter = 0;
   }
 
 function createLogo (string memory tokenURI) public returns(uint256) {
   address logoOwner = msg.sender;
   uint index = tokenCounter++;
   string memory sndx = uint2str(index);
   string memory prehash = strConcat(name(),sndx,tokenURI);
   bytes32 hash = sha256(bytes(prehash));
   uint256 tokenId = uint256(hash);
   _safeMint(logoOwner, tokenId);
   _setTokenURI(tokenId, tokenURI);
   return tokenId;
}

As shown above, the basic NFT provides common attributes of symbol, name, owner, and token identifier.

Our LogoCoin contract further defines some attribute types, but the specific attribute values are stored in a Token URI. The tokenURI is managed by an extension to the ERC-721 contract.

The URIStorage extension is used for tokens for which substantial storage space is required. This URI points to a JSON file containing metadata describing the asset related to this token. The elements of this JSON object conform to the ERC-721 metadata schema[9].

{
 "name": "Object Computing Cube",
 "description": "The primary logo for OCI, a 2D projection of a cube where the three visible faces are constructed from the letters O, C, and I.",
 "image": "https://objectcomputing.com/themes/objectcomputing/images/header-logo.svg",
 "attributes": [
   {
     "Traits_type":"palette",
     "name": "color",
     "Value": "fullcolor"
   },
   {
     "Traits_type": "scale",
     "name": "size",
     "value": "medium"
   },
   {
     "Traits_type": "string",
     "name": "tag",
     "value": ""
   }
 ]
}
 

The elements of this object found in the schema are the name, description, and image URI. The attributes field is not part of the schema as I could find now, but it has been proposed and has been demonstrated to work in other NFT examples[1].

Using these attributes in this way allows for off-chain storage of arbitrary assets. Collectively, the contents of this metadata JSON object are referred to by the tokenURI value. The tokenURI can be used as an argument to the token-creating function, where it may be used to generate a unique ID for the token being created. Another benefit of the tokenURI is that it is referenced by 3rd party NFT marketplaces such as OpenSea[7].

In some cases, the arbitrary attributes could be stored on chain by providing collections of tokenId-keyed maps for each attribute value. However, doing that will not allow you to avoid the use of a tokenURI JSON file.

This JSON file needs to be placed somewhere publicly accessible. It is up to you to use a service, such as the InterPlanetary File System (IPFS), to decentralize the file, as well as the image or any other indirectly referenced asset data. While IPFS is a popular file decentralizing service, it is not the only one. Another example is Storj[10], a cloud based storage service.

Placing The Burden On The Client

The example code shown above points out that there is not a whole lot of work happening in these basic NFT contracts. Certainly contracts may be more involved.

Imagine an NFT contract that represents a house. Before that house token may be transferred to another owner, a substantial number of preconditions must be met. The token’s smart contract is ideally suited to the task, but just like with our LogoCoin, the client interacting with the contract must manage a significant share of the responsibility. 

To help offset the burden to be shouldered by the NFT clients, some companies are establishing marketplaces for creators who are not blockchain engineers. These sites address the challenges by providing a second layer of smart contracts and other utilities to handle most of the tedium related to defining an asset, minting the token, transferring ownership, etc.

In Conclusion

Hopefully now you have a better understanding of what a non-fungible token is and what is necessary to create one and use it, at least on the Ethereum blockchain.

I believe the use of NFTs will grow beyond the collectibles marketplace and begin to appear in more tangible markets, such as real estate. The biggest challenges I believe are related to the decentralized storage of currently off-chain assets, such as those found in what is referred to as the tokenURI above. These challenges are being addressed by projects such as the IPFS and Near.io.

A more significant problem I think is that of digital asset counterfeiting. In this example, I used a logo file that is stored in the open. While that is a valid option, it is not safe because anyone is able to grab that logo image and repurpose it or tokenize it again. A more secure solution would be to require some sort of cryptographic signature in order to access the content of that file, but the infrastructure required to do so is beyond the scope of this article.

References and Acknowledgements

  1. How To Create NFTs With Solidity
  2. NFTs For Creators
  3. ERC-721 Non-Fungible Token Standard | ethereum.org
  4. OpenZeppelin contracts/token/ERC721
  5. Saturday Night Live succinctly describes NFTs
  6. SETT article on EOSIO smart contracts
  7. Opensea.io, an open marketplace for NFTs
  8. Introduction to Smart Contracts
  9. Ethereum Improvement Proposal 721
  10. Decentralized Data Storage, storj.io

I would like to acknowledge the contribution of Jack DiSalvatorein helping me work with the development tools provided for work with the Ethereum blockchain.

Full Text Of The LogoCoin.sol Example Token

  1. // SPDX-License-Identifier: MIT
  2.  
  3. pragma solidity >=0.4.22 <0.9.0;
  4.  
  5. import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
  6.  
  7.  
  8. contract LogoCoin is ERC721URIStorage {
  9.  
  10. // using Strings for tagline;
  11.  
  12.  
  13. enum Palette {grayscale, twocolor, fullcolor}
  14. enum Scale {icon, small, medium, large}
  15. struct LogoAttributes {
  16. Palette color;
  17. Scale size;
  18. string tag;
  19. }
  20. uint public tokenCounter;
  21.  
  22. mapping (uint256 => LogoAttributes) private _attributes;
  23.  
  24. constructor ()
  25. ERC721("LogoCoin","LOGO")
  26. {
  27. tokenCounter = 0;
  28. }
  29.  
  30. /*
  31. * tokenURI contains the attributes such as color palette selection and
  32. * scale size, and image location
  33. */
  34.  
  35. function createLogo (string memory tokenURI)
  36. public returns(uint256) {
  37. address logoOwner = msg.sender;
  38. uint index = tokenCounter++;
  39. string memory sndx = uint2str(index);
  40. string memory prehash = strConcat(name(),sndx,tokenURI);
  41. bytes32 hash = sha256(bytes(prehash));
  42. uint256 tokenId = uint256(hash);
  43. _safeMint(logoOwner, tokenId);
  44. _setTokenURI(tokenId, tokenURI);
  45. return tokenId;
  46. }
  47.  
  48. // conversion function found on stackoverflow.com
  49. // https://stackoverflow.com/questions/47129173/how-to-convert-uint-to-string-in-solidity
  50. function uint2str(uint _i) internal pure returns (string memory _uintAsString) {
  51. if (_i == 0) {
  52. return "0";
  53. }
  54. uint j = _i;
  55. uint len;
  56. while (j != 0) {
  57. len++;
  58. j /= 10;
  59. }
  60. bytes memory bstr = new bytes(len);
  61. uint k = len;
  62. while (_i != 0) {
  63. k = k-1;
  64. uint8 temp = (48 + uint8(_i - _i / 10 * 10));
  65. bytes1 b1 = bytes1(temp);
  66. bstr[k] = b1;
  67. _i /= 10;
  68. }
  69. return string(bstr);
  70. }
  71. // concatenation function found on stackoverflow.com
  72. //https://ethereum.stackexchange.com/questions/729/how-to-concatenate-strings-in-solidity
  73. function strConcat(string memory _a, string memory _b, string memory _c) internal pure returns (string memory){
  74. bytes memory _ba = bytes(_a);
  75. bytes memory _bb = bytes(_b);
  76. bytes memory _bc = bytes(_c);
  77. string memory abcde = new string(_ba.length + _bb.length + _bc.length);
  78. bytes memory babcde = bytes(abcde);
  79. uint k = 0;
  80. for (uint i = 0; i < _ba.length; i++) babcde[k++] = _ba[i];
  81. for (uint i = 0; i < _bb.length; i++) babcde[k++] = _bb[i];
  82. for (uint i = 0; i < _bc.length; i++) babcde[k++] = _bc[i];
  83. return string(babcde);
  84. }
  85. }

Software Engineering Tech Trends (SETT) is a regular publication featuring emerging trends in software engineering.


secret