ERC-721 NFT Token Standard

This guide explains the ERC-721 non-fungible token (NFT) standard, how to create your own NFT collection on Ettios, and best practices for implementation.

Prerequisites

Before you begin, make sure you have:

  • Basic knowledge of Solidity programming
  • A development environment set up (see our Development Environment guide)
  • Some ETTIA for deploying contracts
  • Basic understanding of NFT concepts

What are NFTs?

Non-Fungible Tokens (NFTs) are unique digital assets on the blockchain. Unlike fungible tokens (such as ERC-20 tokens) where each token is identical to every other token, each NFT has unique properties and is not interchangeable with other tokens.

Common NFT Use Cases

  • Digital art and collectibles
  • Gaming items and virtual real estate
  • Event tickets and memberships
  • Domain names
  • Certificates of authenticity
  • Identity and credential verification

ERC-721 Interface

The ERC-721 standard defines a set of functions and events for non-fungible tokens:

ERC-721 Interface
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IERC721 {
    // Core functions
    function balanceOf(address owner) external view returns (uint256 balance);
    function ownerOf(uint256 tokenId) external view returns (address owner);
    function safeTransferFrom(address from, address to, uint256 tokenId) external;
    function transferFrom(address from, address to, uint256 tokenId) external;
    function approve(address to, uint256 tokenId) external;
    function getApproved(uint256 tokenId) external view returns (address operator);
    function setApprovalForAll(address operator, bool _approved) external;
    function isApprovedForAll(address owner, address operator) external view returns (bool);
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;

    // Events
    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);

    // Optional extension: metadata
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function tokenURI(uint256 tokenId) external view returns (string memory);
}

Key Functions

balanceOf(address owner)

Returns the number of NFTs owned by a specific address.

ownerOf(uint256 tokenId)

Returns the address that owns a specific token ID.

transferFrom(address from, address to, uint256 tokenId)

Transfers ownership of an NFT from one address to another address.

tokenURI(uint256 tokenId)

Returns a URL or other identifier that points to off-chain metadata about the NFT. This is where the actual content, image, and attributes are stored.

Creating an NFT Collection

Let's create a simple NFT collection using OpenZeppelin's contracts. First, install the OpenZeppelin Contracts library:

Terminal
npm install @openzeppelin/contracts

Now, create a file called MyNFTCollection.sol with the following code:

MyNFTCollection.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract MyNFTCollection is ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    
    // Maximum supply (optional)
    uint256 public constant MAX_SUPPLY = 10000;
    
    // Base URI for metadata
    string private _baseTokenURI;

    constructor(string memory name, string memory symbol, string memory baseTokenURI) 
        ERC721(name, symbol) 
        Ownable(msg.sender)
    {
        _baseTokenURI = baseTokenURI;
    }
    
    // Function to mint new NFTs
    function mintNFT(address recipient, string memory tokenURI) public onlyOwner returns (uint256) {
        // Ensure we don't exceed max supply
        require(_tokenIds.current() < MAX_SUPPLY, "Max supply reached");
        
        _tokenIds.increment();
        uint256 newItemId = _tokenIds.current();
        
        _mint(recipient, newItemId);
        _setTokenURI(newItemId, tokenURI);
        
        return newItemId;
    }
    
    // Function to update base URI (optional)
    function setBaseURI(string memory baseTokenURI) public onlyOwner {
        _baseTokenURI = baseTokenURI;
    }
    
    function _baseURI() internal view virtual override returns (string memory) {
        return _baseTokenURI;
    }
}

Understanding the NFT Collection

ERC721URIStorage

This extension adds storage for token URIs, which point to the metadata for each NFT. The metadata typically includes the image URL, name, description, and attributes of the NFT.

Token IDs

Each NFT has a unique ID (tokenId). In our example, we're using a counter to generate sequential IDs, but you could implement custom ID generation logic.

Metadata URI

The tokenURI for each NFT typically points to a JSON file with the following structure:

JSON Metadata Example
{
  "name": "NFT Name",
  "description": "Description of the NFT",
  "image": "https://example.com/image.png",
  "attributes": [
    { "trait_type": "Color", "value": "Blue" },
    { "trait_type": "Size", "value": "Large" }
  ]
}

Metadata Storage Options

NFT metadata (including images) can be stored in different ways:

IPFS (Recommended)

InterPlanetary File System is a decentralized storage solution ideal for NFT data. Content on IPFS is addressed by its content hash, ensuring data integrity.

ipfs://QmT5NvUtoM5nWFfrQdVrFtvGfKFmG7AHE8P34isapyhCxX/1.json

Tools: NFT.Storage, Pinata

Arweave

Arweave provides permanent storage for a one-time fee, making it suitable for long-term NFT data persistence.

ar://AcuH5BdC_ib67Z2LQz2h2sptto5XmmPvYdCvLLDCGsA/1.json

Service: Arweave

Important Note on Metadata

Avoid storing metadata on centralized servers that may go offline in the future. NFTs are meant to be permanent, so their metadata should be stored in a permanent, decentralized way.

Deploying Your NFT Collection

Here's an example of deploying your NFT collection using Hardhat:

scripts/deploy-nft.js
const hre = require("hardhat");

async function main() {
  const MyNFTCollection = await hre.ethers.getContractFactory("MyNFTCollection");
  
  // Deploy with collection name, symbol, and base URI
  const myNFT = await MyNFTCollection.deploy(
    "My Amazing NFTs", 
    "MANFT",
    "ipfs://QmYourBaseURIHash/" // Replace with your actual base URI
  );
  
  await myNFT.deployed();
  
  console.log("MyNFTCollection deployed to:", myNFT.address);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

To deploy to the Ettios mainnet:

Terminal
npx hardhat run scripts/deploy-nft.js --network ettiosMainnet

Minting NFTs

After deploying your collection, you can mint new NFTs:

scripts/mint-nft.js
const hre = require("hardhat");

async function main() {
  // Get contract instance
  const nft = await hre.ethers.getContractAt(
    "MyNFTCollection", 
    "YOUR_DEPLOYED_CONTRACT_ADDRESS"
  );
  
  // Mint a new NFT
  // recipientAddress: The address that will receive the NFT
  // tokenURI: Metadata URI for this specific NFT (e.g., "ipfs://QmHash/1.json")
  const tx = await nft.mintNFT(
    "0xRecipientAddress", 
    "ipfs://QmYourTokenURIHash/1.json"
  );
  
  // Wait for the transaction to be confirmed
  await tx.wait();
  
  console.log("NFT minted successfully!");
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Advanced Features

Lazy Minting

Lazy minting allows NFTs to be created without paying the gas fees until the moment of purchase or transfer, making it more cost-effective for creators.

Implementation involves digital signatures and custom transfer logic. See our Lazy Minting guide for more details.

Royalties (ERC-2981)

Implement the ERC-2981 standard to receive royalties from secondary sales of your NFTs on marketplaces that support this standard.

Royalties Implementation
// Add this to your contract imports
import "@openzeppelin/contracts/token/common/ERC2981.sol";

// And implement the interface
contract MyNFTCollection is ERC721URIStorage, ERC2981, Ownable {
  // ...

  // Set default royalty
  constructor(...) {
    _setDefaultRoyalty(msg.sender, 500); // 5% royalty
  }

  // Important: Override supportsInterface for compatibility
  function supportsInterface(bytes4 interfaceId)
    public
    view
    override(ERC721, ERC2981)
    returns (bool)
  {
    return super.supportsInterface(interfaceId);
  }
}

On-chain Metadata

Instead of storing metadata off-chain, you can generate and store it entirely on-chain, ensuring that the complete NFT exists on the blockchain.

This approach is more gas-intensive but provides greater permanence.