ERC-721 (NFT) Overview: Implementation, Security, and Best Practices

This article presents a global overview of ERC-721 standard (NFT), covering its core functions, metadata, security best practices and how to use it with OpenZeppelin.

If you want to know more about the different standards and extensions to represent NFT, you can read my article NFT Standards on Ethereum - ERC-721 and Beyond.

[TOC]

Standard Overview

Every ERC-721 compliant contract must implement the ERC721 and ERC165 interfaces:

A quick summary from the EIP-721

Function/Event Description Return type Remarks / Security Considerations
Transfer (event) Emitted when an NFT is transferred, created (from == 0), or burned (to == 0). - During contract creation, any number of NFTs may be created and assigned without emitting Transfer.<br /
Approval (event) Emitted when an NFT’s approved address 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.
ApprovalForAll (event) Emitted when an operator is enabled or disabled to manage all NFTs of an owner. - -
balanceOf(address _owner) Returns the number of NFTs owned by an address.
Ensure _owner is a valid address; revert if it’s zero.
uint256 It will revert if there is no owner !
ownerOf(uint256 _tokenId) Returns the owner of a specific NFT.
Ensure _tokenId exists; revert if it’s invalid.
address It will revert if there is no owner !
safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) Transfers an NFT, ensuring _to can handle ERC-721 tokens (via onERC721Received). - Use safeTransferFrom instead of transferFrom to prevent tokens from being locked in contracts that don’t implement onERC721Received.
Warning: adds a reentrancy-risk
safeTransferFrom(address _from, address _to, uint256 _tokenId) Same as above but without the extra data parameter. - Preferred over transferFrom to prevent lost tokens in contracts.
transferFrom(address _from, address _to, uint256 _tokenId) Transfers NFT ownership without checking if _to can handle it. - Risk of lost tokens! Ensure _to is an EOA (Externally Owned Account) or a contract that supports ERC-721.
approve(address _approved, uint256 _tokenId) Approves another address to transfer a specific NFT. -
setApprovalForAll(address _operator, bool _approved) Grants/revokes permission for an operator to manage all NFTs of the caller. - Be careful when approving third-party operators (e.g., marketplaces) to prevent mass asset loss.
getApproved(uint256 _tokenId) Returns the approved address for a specific NFT. address Ensure _tokenId exists to avoid reverting transactions.
isApprovedForAll(address _owner, address _operator) Returns whether an operator is approved for all NFTs of an owner. bool Regularly check operator approvals to avoid unauthorized transfers.
supportsInterface(bytes4 interfaceID) (ERC165) Checks if a contract implements a specific interface (e.g., ERC-721). bool Required for ERC-721 compliance. Ensures compatibility with other smart contracts.

ERC-721 Token Receiver Interface (ERC721TokenReceiver)

A wallet/broker/auction application MUST implement the wallet interface if it will accept safe transfers.

The ERC721TokenReceiver interface is crucial for smart contracts that intend to receive ERC-721 tokens securely. It ensures that the recipient contract can handle NFTs properly and prevents tokens from getting stuck in incompatible contracts.


Function Reference Table

Function Description Return type Technical Details & Security Considerations
onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data) → bytes4 Handles the receipt of an NFT when safeTransferFrom() is used. Must return a specific magic value to confirm acceptance. bytes4 - MUST return 0x150b7a02 (bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))) or the transaction will revert.
- Called by the NFT contract on _to (receiver contract).
- The _operator is the sender (could be an approved operator).
- If the recipient contract does not implement this function or returns an incorrect value, the transfer fails.

Metadata

The metadata extension is OPTIONAL for ERC-721 smart contracts-

This allows your smart contract to be interrogated for its name and for details about the assets which your NFTs represent.

ERC-721 Metadata Extension Function Reference Table

Function Description Return type Remarks / Security Considerations
name() Returns the descriptive name of the NFT collection. string The name should be immutable or only modifiable by the contract owner to prevent misleading changes. It should ideally not be modified to maintain consistency.
symbol() Returns the symbol (ticker) of the NFT collection. string Similar to name(), it should be set at deployment or only modifiable by the contract owner to prevent misleading changes. It should ideally not be modified to maintain consistency.
tokenURI(uint256 _tokenId) Returns a unique URI for a given NFT, typically pointing to metadata (e.g., JSON file with image, description, attributes).
URIs are defined in RFC 3986.
Revert if _tokenId is not a valid NFT.
The URI may point to a JSON file that conforms to the “ERC721 Metadata JSON Schema”.
string Ensure _tokenId exists before returning a URI to prevent invalid lookups, otherwise revert
Use IPFS or Arweave instead of centralized servers to ensure metadata immutability and prevent broken links. See my article on Arweave

Build NFT contract

Minimum function

If you use OpenZeppelin as the core library, the majority of ERC-721 functions are already implemented. There are nevertheless several functions, not directly part of ERC-721 standard, which could be relevant to add depending of your use case:

  • Mint, eventually burn
  • Access control or ownership system to protect your sensitive functions (e.g mint/burn)
  • setBaseUri: to set the base URI, common for all tokens URI
  • baseURI(): getter to return Base URI for computing {tokenURI}. If set, the resulting URI for each token will be the concatenation of the baseURI and the tokenId. OpenZeppelin only provides the internal function _baseURI()which can not be called outside of the NFT contract.

Mint

With OpenZeppelin, the internal function _mint is not recommended and it is preferable to use ` _safeMint` instead.

_safeMintwill check if the recipient of the newly created NFT is an EOA or a smart contract.

If it is a contract, the function will check that the contract implements the interface IERC721Receiver which indicates that this contract can manage ERC-721 token.

See docs.openzeppelin.com/contracts/5.x/api/token/erc721#ERC721-_mint-address-uint256-

Security

Transfer

safeTransferFrom

If you build a contract to manage and transfer NFT, e.g an NFT marketplace:

Main reference: Pessimistic - Auditing Tips for NFT Projects

Transfer and TransferFrom

OpenZeppelin recommand to use safeTransferFrominstead even if it adds a reentrancy risk

Note that the caller is responsible to confirm that the recipient is capable of receiving ERC-721 or else they may be permanently lost. Usage of safeTransferFrom prevents loss, though the caller must understand this adds an external call which potentially creates a reentrancy vulnerability

See docs.openzeppelin.com/contracts/5.x/api/token/erc721#IERC721-transferFrom-address-address-uint256-

And Quillhash - NFT-Attack-Vectors/blob/main/data/16.md

Approval/allowance

For ERC-20 token, if a token holder wants to update the amount approved to a specific spender, there is the possibility for this spender to front-run the approval by spending the approval before the new approval is set, see ERC20 API: An Attack Vector on the Approve/TransferFrom Methods-

ERC-721 is less concerned by this problem because each NFT is unique, the approval quantity is none or one. However, if a token holder wants to revoke the approval given to a spender, there is however always the possibility for this spender to spend the approval before it is approved on the blockchain (front-running).

ERC721 detector

Aderyn - IncorrectERC721InterfaceDetector

Aderyn has a detector to check if the ERC-721 interface is correctly implemented or not.

https://github.com/Cyfrin/aderyn/blob/aderyn-v0.5.8/aderyn_core/src/detect/high/incorrect_erc721_interface.rs

The detector will notably check the following thing:

  • The return type for each function
  • If the signature match the interface

Here a summary tab for the return value:

ERC-721 Function Expected Return Type Problem if not
balanceOf(address owner) uint256 If it doesn’t return uint256, it’s captured.
getApproved(uint256 tokenId) address If it doesn’t return address, it’s captured.
ownerOf(uint256 tokenId) address If it doesn’t return address, it’s captured.
safeTransferFrom(address from, address to, uint256 tokenId) no return (void) If it returns anything, it’s captured.
transferFrom(address from, address to, uint256 tokenId) no return (void) If it returns anything, it’s captured.
approve(address to, uint256 tokenId) no return (void) If it returns anything, it’s captured.
setApprovalForAll(address operator, bool approved) no return (void) If it returns anything, it’s captured.
isApprovedForAll(address owner, address operator) bool If it doesn’t return bool, it’s captured.

Reference: summary tab made with the help of ChatGPT

Example

This example is a very simple basic NFT contract with only a mint function, the possibility to put in pause the contract and an access control with an ownership system.

To define the tokenId of an NFT, the contract use a counter (tokenId) which is incremented afer the mint.

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

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

contract MyNFT is ERC721, Ownable, Pausable {
	uint256 tokenId;
    function mint(address to) public onlyOwner whenNotPaused {
        _mint(to, nextTokenId);
        nextTokenId++;
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }
}

Mindmap

Created with the help of ChatGPT

erc721-mindmap

Error management (ERC-6093)

OpenZeppelin implements for their ERC-721 implementation since the version 5.0.0 the standard eip-6093 created by their team.

This EIP defines a standard set of custom errors for commonly-used tokens, notably ERC-721 tokens.

Summary tab

Error Description Recommended Must / MUST NOT Usage Guidelines
ERC721InvalidOwner(address owner) Address can’t be an owner (e.g., address(0)). Used in balance queries. Use for addresses disallowed as owners (e.g., address(0)). MUST NOT be used for transfers. Use ERC721IncorrectOwner instead.
ERC721NonexistentToken(uint256 tokenId) The tokenId does not exist (not minted or burned). N/A MUST be used only for non-minted or burned tokens. Helps prevent interactions with invalid tokens.
ERC721IncorrectOwner(address sender, uint256 tokenId, address owner) Error related to token ownership. Used in transfers. N/A sender MUST NOT be the current owner.
MUST NOT be used for approval operations.
Used to enforce correct ownership during transfers.
ERC721InvalidSender(address sender) Failure related to the token sender. Used in transfers. Use for disallowed transfers from address(0). MUST NOT be used for approval operations. MUST NOT be used for ownership or approval checks. Use ERC721IncorrectOwner or ERC721InsufficientApproval instead.
ERC721InvalidReceiver(address receiver) Failure related to the token receiver. Used in transfers. Use for disallowed transfers to address(0). Use for transfers to non-ERC721Receiver contracts. MUST NOT be used for approval operations. Helps prevent lost tokens by rejecting invalid receivers.
ERC721InsufficientApproval(address operator, uint256 tokenId) Operator lacks approval for the transfer. N/A isApprovedForAll(owner, operator) MUST be false. getApproved(tokenId) MUST NOT be operator. Ensures transfers only occur with correct approvals.
ERC721InvalidApprover(address approver) Failure related to the approver (token owner granting approval). Used in approvals. Use for disallowed approvals from address(0). MUST NOT be used for transfers. Prevents invalid approval attempts.
ERC721InvalidOperator(address operator) Failure related to the operator being approved. Used in approvals. Use for disallowed approvals to address(0). operator MUST NOT be the token owner.
MUST NOT be used for transfers.
Use ERC721InsufficientApproval instead.

Mindmap

Created with the help of ChatGPT

erc721-6093-mindmap

References

You might also enjoy