OpenZeppelin's ERC721: A User Guide

OpenZeppelin's ERC721: A User Guide
⚠️
Version of OpenZeppelin introduced: v5.0.0

In this post, we'll introduce ERC721, a standard often used in smart contracts, especially for NFTs. Here are some key points about what you can do with ERC721:

  • Easily implement NFTs
  • Follow a standardized implementation

While it's simple, implementing something similar on your own could lead to significant security issues. Hence, we recommend using ERC721 for a straightforward approach.

What is ERC721?

OpenZeppelin's ERC721 allows you to implement NFTs on the Ethereum Virtual Machine (EVM). The OpenZeppelin library provides a safe, standard-compliant implementation of ERC721, known for its security, efficiency, and extensibility.

Inside ERC721

Here's a brief look at the ERC721 implementation:

// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (token/ERC721/ERC721.sol)

pragma solidity ^0.8.20;

import {IERC721} from "./IERC721.sol";
import {IERC721Receiver} from "./IERC721Receiver.sol";
import {IERC721Metadata} from "./extensions/IERC721Metadata.sol";
import {Context} from "../../utils/Context.sol";
import {Strings} from "../../utils/Strings.sol";
import {IERC165, ERC165} from "../../utils/introspection/ERC165.sol";
import {IERC721Errors} from "../../interfaces/draft-IERC6093.sol";

abstract contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Errors {
    using Strings for uint256;

    // Token name and symbol
    string private _name;
    string private _symbol;

    // Mapping for token ownership and balances
    mapping(uint256 => address) private _owners;
    mapping(address => uint256) private _balances;

    // Mapping for token approvals
    mapping(uint256 => address) private _tokenApprovals;
    mapping(address => mapping(address => bool)) private _operatorApprovals;

    constructor(string memory name_, string memory symbol_) {
        _name = name_;
        _symbol = symbol_;
    }

    // Other functions and implementations...
}

The storage is minimal, and the constructor only requires the NFT's name and symbol.

Storage and Contract

// Token name
string private _name;

// Token symbol
string private _symbol;
// Token owners - tokenId => owner address
mapping(uint256 => address) private _owners;
// Owner's token count - address => count
mapping(address => uint256) private _balances;
// Token approvals - tokenId => approved address
mapping(uint256 => address) private _tokenApprovals;
// Operator approvals - owner => operator => bool
// The operator can manage the owner's tokens
mapping(address => mapping(address => bool)) private _operatorApprovals;

/**
 * @dev Initializes the contract by setting a `name` and a `symbol` to the token collection.
 * @param name_ The name of the token
 * @param symbol_ The symbol of the token
 */
constructor(string memory name_, string memory symbol_) {
    _name = name_;
    _symbol = symbol_;
}

The storage is minimally structured, and the constructor only requires the name and symbol of the NFT.

_update

/**
 * @param to The address to transfer to
 * @param tokenId The ID of the token to transfer
 * @param auth The address of the authorizer
 **/
function _update(
    address to,
    uint256 tokenId,
    address auth
) internal virtual returns (address) {
    // Get the token owner
    address from = _ownerOf(tokenId);

    // Perform (optional) operator check
    // If the authorizer is not the zero address, check if it's the owner or operator
    if (auth != address(0)) {
        _checkAuthorized(from, auth, tokenId);
    }

    // Execute the update
    // Update the token owner
    // If the token owner is not the zero address, reduce the owner's token count
    if (from != address(0)) {
        // Clear approval. No need to re-authorize or emit the Approval event
        _approve(address(0), tokenId, address(0), false);

        // Reduce the token owner's token count
        unchecked {
            _balances[from] -= 1;
        }
    }

    // If the transfer address is not the zero address, increase the transfer address's token count
    if (to != address(0)) {
        // Increase the token owner's token count
        unchecked {
            _balances[to] += 1;
        }
    }

    // Update the token ownership
    _owners[tokenId] = to;

    emit Transfer(from, to, tokenId);

    return from;
}

This function performs private operations such as transferring token ownership.

There are mainly three patterns for different operations, so be careful when using it!

Patternto (Transfer Address)tokenId (Token ID)auth
mintAddress of the recipientTarget token IDZero address
burnZero addressTarget token IDZero address
Change of ownershipTransfer addressTarget token IDExecutor's address

In addition to these, there are other internal processes that can forcefully change ownership, so if you modify the process using _update, make sure to write thorough tests!

_mint (_safeMint)

OpenZeppelin recommends using _safeMint instead of _mint, so it's best to avoid using _mint directly.

However, since _safeMint internally uses _mint, we'll introduce the contents of _mint here.

_mint

// @param to The address to transfer to
// @param tokenId The ID of the token to transfer
function _mint(address to, uint256 tokenId) internal {
    // Revert if to is the zero address
    if (to == address(0)) {
        revert ERC721InvalidReceiver(address(0));
    }
    // Update the token owner and return the previous owner
    address previousOwner = _update(to, tokenId, address(0));
    // Revert if the previous owner is not the zero address
    if (previousOwner != address(0)) {
        revert ERC721InvalidSender(address(0));
    }
}

When minting, the transfer address is required, and the previous owner must be the zero address (no owner). If these conditions are not met, the transaction will revert.

_safeMint

function _safeMint(address to, uint256 tokenId) internal {
    _safeMint(to, tokenId, "");
}

function _safeMint(
    address to,
    uint256 tokenId,
    bytes memory data
) internal virtual {
    _mint(to, tokenId);
    _checkOnERC721Received(address(0), to, tokenId, data);
}

Although it's a bit confusing, the upper _safeMint calls the lower _safeMint for processing, and finally checks if the transfer was properly received.

_burn

function _burn(uint256 tokenId) internal {
    // Pass the necessary arguments for burning to _update
    address previousOwner = _update(address(0), tokenId, address(0));
    // Revert if the previous owner is the zero address
    if (previousOwner == address(0)) {
        revert ERC721NonexistentToken(tokenId);
    }
}

When using this function to burn, it can burn the token regardless of the owner's intent. If the specification requires the owner's approval, you need to check that the executor has approval before this step, or use _update as follows:

_update(address(0), tokenId, msg.sender);

transferFrom

For transferFrom, there is also safeTransferFrom that checks if the transfer was received, so it's recommended to use safeTransferFrom for normal operations.

// @param from The address of the sender
// @param to The address of the recipient
// @param tokenId The ID of the token to transfer
function transferFrom(
    address from,
    address to,
    uint256 tokenId
) public virtual {
    // Revert if to is the zero address
    if (to == address(0)) {
        revert ERC721InvalidReceiver(address(0));
    }
    // Transfer tokenId to to and check if the executor is approved
    address previousOwner = _update(to, tokenId, _msgSender());
    // Revert if the previous owner is not from
    if (previousOwner != from) {
        revert ERC721IncorrectOwner(from, tokenId, previousOwner);
    }
}

Example Code

Here's a simple implementation where only the contract owner can mint and burn tokens:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MyNFT is ERC721, Ownable {
    uint256 private _nextTokenId;

    constructor(address initialOwner) ERC721("MyNFT", "MN") Ownable(initialOwner) {}

    function safeMint(address to) public onlyOwner {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
    }

    function burn(uint256 tokenId) public onlyOwner {
        _update(address(0), tokenId, _msgSender());
    }
}

This is just a basic implementation; OpenZeppelin allows for various customizations.

Conclusion

OpenZeppelin's ERC721 provides an easy and standardized way to implement and manage NFTs. The main advantages of using OpenZeppelin are the ease of NFT implementation and compliance with the ERC721 standard in a security-verified manner. It is recommended to use OpenZeppelin instead of developing similar implementations individually due to potential security risks. In this post, we introduced a basic implementation where only the contract owner can mint and burn tokens. However, OpenZeppelin offers a wide range of customizations for NFT projects. Leverage this robust and flexible ERC721 implementation to efficiently and securely advance your projects.