Implementing an Interface for Non-Fungible Tokens

October 11, 2021

Overview

Non-fungible tokens (NFTs) are tokens that represent proof of ownership of unique items. Each token is non-divisible and has a unique identifier linked to one owner. In general, NFTs conform to a set of standards that are governed by smart contracts stored on a blockchain. The most common standard is ERC-721.

ERC-721

ERC-721 is an open standard that describes how to build non-fungible tokens on the Ethereum blockchain. The standard describes the transfer, approval and information retrieval of tokens. Every ERC-721-compliant contract must implement the ERC721 and ERC165 interfaces, as outlined in EIP-721.

The following functions (corresponding to the relevant interface) must be implemented:

  • interface ERC721
    • balanceOf(owner)
    • ownerOf(tokenId)
    • transferFrom(from, to, tokenId)
    • approve(to, tokenId)
    • getApproved(tokenId)
    • setApprovalForAll(operator, _approved)
    • isApprovedForAll(owner, operator)
    • safeTransferFrom(from, to, tokenId)
    • safeTransferFrom(from, to, tokenId, data)
  • interface ERC165
    • supportsInterface(interfaceId)
  • interface ERC721Enumerable (optional)
    • totalSupply()
    • tokenOfOwnerByIndex(owner, index)
    • tokenByIndex(index)
  • interface ERC721Metadata (optional)
    • name()
    • symbol()
    • tokenURI(tokenId)

The following events (corresponding to the relevant interface) must be implemented:

  • interface ERC721
    • Transfer(from, to, tokenId)
    • Approval(owner, approved, tokenId)
    • ApprovalForAll(owner, operator, approved)

Metadata

Most NFT collections choose to store the underlying assets that they represent off-chain, usually with a decentralised storage provider. This helps reduce gas fees related to storage and the processing of transactions. The details of these assets may be described using the metadata interface extension (interface ERC721Metadata).

In order to link the contract with the details of the underlying asset, contracts utilise the tokenURI function (specific to a given token) to return a JSON file that contains any necessary information regarding the collection and the assets held off-chain. The original metadata JSON schema was proposed as follows:

{
  "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."
    }
  }
}

Current implementations of the metadata interface are more flexible and don't excatly conform to this schema, but the general principle remains the same.

ABI

In order to interact with smart contracts, you need to know it's Application Binary Interface (ABI) - a low-level interface that defines the properties exposed by a smart contract. An ABI is used to encode and decode binary data at a low level, providing a mechanism to interact with smart contracts at a high level. Data is encoded according to its type and then decoded according to its schema, as specified by the interface.

All ERC-721-compliant contracts will expose an almost identical ABI, with an option to further extend the interface with additional functionality. The ABI of a smart contract is specified in JSON format given by an array of function, event and error descriptions.

An ABI usually results in a chicken-and-egg problem, where we need the smart contract to know the interface it exposes but we need the ABI to interact with the smart contract. This is especially true in smart contracts that extend the standard interface.

Function Objects

The format of a function object:

  • type
    • function
    • constructor
    • receive
    • fallback (default)
  • name
  • inputs - an array of objects
    • name
    • type
    • components
  • outputs
  • stateMutability
    • pure - function does not read blockchain state
    • view - function does not modify blockchain state
    • nonpayable - function does not accept Ether (default)
    • payable - function accepts Ether

Event Objects

The format of an event object:

  • type
    • event
  • name
  • inputs - an array of objects
    • name
    • type
    • components
    • indexed - true if the field is part of the log's topics, false if it one of the log's data segment
  • anonymous

Error Objects

The format of an error object:

  • type
    • error
  • name
  • inputs - an array of objects
    • name
    • type
    • components

Example ABI

For example, consider the basic contract below written in Solidity:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
 
 
contract ExampleContract {
    constructor() { b = hex"74645678901234000890123456789321"; }
    function ExampleFunction(uint256 arg1) public pure { }
    event ExampleEvent(uint256 indexed arg1, bytes32 arg2);
    error ExampleError(uint256 arg1);
    bytes32 exampleConstant;
}

The corresponding ABI for this contract is:

[
  {
    "type": "function",
    "inputs": [{ "name": "arg1", "type": "uint256" }],
    "name": "ExampleFunction",
    "outputs": []
  },
  {
    "type": "event",
    "inputs": [
      { "name": "arg1", "type": "uint256", "indexed": true },
      { "name": "arg2", "type": "bytes32", "indexed": false }
    ],
    "name": "ExampleEvent"
  },
  {
    "type": "error",
    "inputs": [{ "name": "arg1", "type": "uint256" }],
    "name": "ExampleError"
  }
]

ERC-721 ABI

Given that all ERC-721-compliant contracts implement the same interface, the following ABI will be identical (but not exhuastive) for all ERC-721 smart contracts:

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "balanceOf",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "ownerOf",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "safeTransferFrom",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "bytes",
        "name": "_data",
        "type": "bytes"
      }
    ],
    "name": "safeTransferFrom",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "approve",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "getApproved",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "operator",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "approved",
        "type": "bool"
      }
    ],
    "name": "setApprovalForAll",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "operator",
        "type": "address"
      }
    ],
    "name": "isApprovedForAll",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "constant": false,
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "safeTransferFrom",
    "outputs": [],
    "payable": false,
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "constant": false,
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "bytes",
        "name": "_data",
        "type": "bytes"
      }
    ],
    "name": "safeTransferFrom",
    "outputs": [],
    "payable": false,
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes4",
        "name": "interfaceId",
        "type": "bytes4"
      }
    ],
    "name": "supportsInterface",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalSupply",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "index",
        "type": "uint256"
      }
    ],
    "name": "tokenOfOwnerByIndex",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "index",
        "type": "uint256"
      }
    ],
    "name": "tokenByIndex",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "name",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "symbol",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "tokenURI",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "Transfer",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "approved",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "Approval",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "operator",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "approved",
        "type": "bool"
      }
    ],
    "name": "ApprovalForAll",
    "type": "event"
  }
]