ERC721 And NFTs

MosaadMosaad
11 min read

Continuing my journey into smart contract development and security auditing, I delved into the ERC721 Token Standard, the foundation of non-fungible tokens (NFTs) on Ethereum. In this article, I share what I’ve learned so far: the challenges ERC721 addresses, its key functions and events, and the crucial security considerations developers and auditors must understand.

What are NFTs?

Non-fungible Tokens (NFTs) are a product of the ERC721 Token Standard, created on Ethereum.

NFTs are non-fungible tokens, which means they are unique from one another. This means that one NFT cannot be interchanged with another.

How do they differ from fungible tokens?

Fungible tokens, like ERC20s, are similar to a dollar. Any single dollar can be swapped with any other and no value is lost. NFTs, by contrast, are unique in themselves with different properties from token to token.

What are the use cases of NFTs?

Their use cases are growing day by day, but the best way to think about them is as digital art or digital assets.

Because of their unique nature, they have been widely used as a medium for digital art and a means to collect digital art and trade on the metaverse. But NFT is only a standard that could be used for many things beyond that, some turn them into assets in games, assets to keep records, or means to grant access to services or events.

At their core, NFTs are tokens which exist on a smart contract platform. They can be viewed, bought, and sold on marketplaces such as OpenSea and Rarible. The marketplaces aren't required, of course, but the UI/UX is generally better than a command line.

Difference Between ERC20 and ERC721

They differ from each other in some fundamentals:

Ownership

In the ERC20 standard, the owner is mapped to the amount of tokens they have.

mapping (address => uint256) ownersToAmount;

But in ERC721, because of the uniqueness of the tokens, every token gets a token ID, and this token ID is mapped to the owner's address.

mapping (uint256 => address) tokenToOwner;

Fungibility

NFTs are non-fungible. This means each token is unique and cannot be interchanged with another. ERC20s, on the other hand, are fungible. Any LINK token is identical in property and value to any other LINK token.

ERC20 vs ERC721

FeatureERC20 (Fungible)ERC721 (Non-Fungible)OwnershipMapped to balancesEach token ID mapped to a unique ownerInterchangeable?Yes — every unit is identicalNo — each has unique properties

How is an NFT stored to maintain uniqueness?

  1. Unique tokenId: This is a distinct number for each token. No two NFTs in the same contract have the same ID.

  2. Metadata / tokenURI: This is a link or pointer that stores information about the NFT, what it is, what it looks like, or what it contains.

Example:

If the NFT represents a character in a game:

  • tokenID might be 1234, marking this particular character.

  • tokenURI holds the stats, name, artwork link, and details that make this character different from others.

Why Not Store Everything On-Chain?

Ethereum (and most blockchains) have high fees for storing data.

  • Every bit of data written to the chain costs money.

  • Large files like images or complex metadata are extremely expensive to store directly on-chain.

So, instead of saving all metadata and images on-chain, NFTs use references.

How Does tokenURI Work?

The ERC721 standard includes a function for each NFT called tokenURI.

  • tokenURI returns a link (URL or IPFS hash) to the metadata for this NFT.

  • The metadata is usually a JSON file with fields like: json

      {
          "name": "Hero #1234",
          "description": "A mighty warrior NFT!",
          "image": "https://ipfs.io/ipfs/<hash>",
          "attributes": [ ... ]
      }
    
  • The image field usually points to an image hosted somewhere (rarely on-chain, usually off-chain).

Off-Chain vs On-Chain Metadata

  • On-chain metadata:

    • Secure and decentralized; no risk of loss, but very expensive.
  • Off-chain metadata:

    • Cheap and flexible, but you depend on external servers to keep data available.

    • If the server disappears, so does your NFT's information (image, attributes, etc.).

IPFS (InterPlanetary File System) is a popular middle-ground:

  • It's a decentralized way to host files so they're not tied to one company or server.

  • However, data can sometimes become unavailable if no one hosts it.

The Typical NFT Storage Flow

  1. Upload Image to IPFS (or another off-chain service): The image gets a unique IPFS hash.

  2. Create Metadata JSON (includes the IPFS image hash): This describes the NFT's name, description, image, and attributes.

  3. Upload Metadata to IPFS: The JSON file itself gets an IPFS hash.

  4. Set tokenURI for the NFT to the metadata's IPFS hash: Now, anyone who queries this token's URI will get the metadata, which points to the image.


Now let's talk more about the ERC721 standard and how to implement it. (ERC-721: Non-Fungible Token Standard)

Must Be Implemented Functions

  1. First, any ERC721 standard-compliant contract must implement the ERC721 and ERC165 interfaces.

    What is the ERC165 Interface?

    • ERC165 is a standard interface that allows smart contracts to declare which interfaces they implement.

    • It defines one function: solidity

        function supportsInterface(bytes4 interfaceID) external view returns (bool);
      
    • The function takes a bytes4 argument, which is the interface ID — calculated by XOR'ing the function selectors (the first 4 bytes of the keccak256 hash of each function signature) in the interface.

    • When called, the function returns true if the contract implements the queried interface and false otherwise.

    • This allows other contracts and tools to programmatically check if a contract supports a given interface, enhancing interoperability and safety.

    function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns(bool) {
        return 
                interfaceId == type(IERC721).interfaceId ||
                interfaceId == type(IERC721Metadata).interfaceId ||
                super.supportsInterface(interfaceId); // To include the supported interfaces by the parent contract (ERC165).
                }
  1. The ERC721 interface consists of functions and events that must be implemented:

    1. Events

      • Transfer → emitted whenever a token's ownership changes, except when tokens are created in the constructor (during minting).

      • Approval → emitted whenever the owner of a token approves another address to spend it. Once ownership changes, the approved address is reset to 0x0.

      • ApprovalForAll → emitted whenever an owner allows (or revokes) an operator to manage all their tokens.

        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);
  1. Functions that return values

    • balanceOf(address) → returns the number of tokens owned by an account.

    • ownerOf(uint256 tokenId) → returns the current owner of the queried token.

    • getApproved(uint256 tokenId) → returns the address approved to spend the given NFT.

    • isApprovedForAll(address owner, address operator) → returns whether an operator is authorized to manage all of the owner's tokens.

        /* These functions are only for clarification; you must implement logic to ensure security */
        mapping(uint256 tokenId => address) private _owners;

        mapping(address owner => uint256) private _balances;

        mapping(uint256 tokenId => address) private _tokenApprovals;

        mapping(address owner => mapping(address operator => bool)) private _operatorApprovals;

        function balanceOf(address owner) public view virtual returns (uint256) {
          return _balances[owner];
        }

        function ownerOf(uint256 tokenId) public view virtual returns (address) {
          return _owners[tokenId];
        }

        function isApprovedForAll(address owner, address operator) public view virtual returns (bool) {
          return _operatorApprovals[owner][operator];
        }

        function _getApproved(uint256 tokenId) internal view virtual returns (address) {
          return _tokenApprovals[tokenId];
        }
  1. Functions that modify state

    • approve(address to, uint256 tokenId) → sets or resets an approved address for a given NFT. Caller must be the owner or an authorized operator.

    • setApprovedForAll(address operator, bool approved) → enables or disables an operator to manage all NFTs held by the owner.

    • transferFrom(address from, address to, uint256 tokenId) → transfers ownership of an NFT, after verifying that the caller is authorized. Does not check if to can handle NFTs, which may lead to lost tokens.

    • safeTransferFrom(address from, address to, uint256 tokenId) → same as transferFrom, but also verifies that to can handle NFTs (calls onERC721Received). Reverts if not supported.

        /* These functions are only for clarification; you must implement logic to ensure security */
        // This approve function should be called through approve(address to, uint256 tokenId) to match the interface.
        function approve(address to, uint256 tokenId, address caller, bool emitEvent) internal virtual {
                if (emitEvent || caller != address(0)) {
                    address owner = _requireOwned(tokenId);

                    if (caller != address(0) && owner != caller && !isApprovedForAll(owner, caller)) {
                        revert ERC721InvalidApprover(caller);
                    }

                    if (emitEvent) {
                        emit Approval(owner, to, tokenId);
                    }
                }

                _tokenApprovals[tokenId] = to;
            }

        function _setApprovalForAll(address owner, address operator, bool approved) internal virtual {
                if (operator == address(0)) {
                    revert ERC721InvalidOperator(operator);
                }
                _operatorApprovals[owner][operator] = approved;
                emit ApprovalForAll(owner, operator, approved);
            }

        function transferFrom(address from, address to, uint256 tokenId) internal virtual returns (address) {
                address caller = msg.sender;

                if (caller != address(0)) {
                    checkAuthorized(from, caller, tokenId); // Supposed a function to check if the caller is authorized or not.
                }

                if (from != address(0)) {
                    approve(address(0), tokenId, address(0), false); // resetting the approved accounts.

                    unchecked {
                        _balances[from] -= 1;
                    }
                }

                if (to != address(0)) {
                    unchecked {
                        _balances[to] += 1;
                    }
                }

                _owners[tokenId] = to;

                emit Transfer(from, to, tokenId);
            }

        function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual {
                transferFrom(from, to, tokenId);
                checkOnERC721Received(from, to, tokenId, data); // function to check if the recipient contract implements receiver logic (onERC721Received). It must return (IERC721Receiver.onERC721Received.selector) to pass and not revert.
            }
  1. Additional Functions:

    • Any contract that wants to safely receive NFTs via safeTransferFrom must implement onERC721Received.

    • Implementing mint function so the owner can mint tokens, In the child contract override it to ensure that only admin can mint tokens.

        function onERC721Received(
                address operator,
                address from,
                uint256 tokenId,
                bytes calldata data
            ) external pure override returns (bytes4) {
                return IERC721Receiver.onERC721Received.selector;
            }

        function _mint(address to, uint256 tokenId) internal virtual {
            require(to != address(0), "Cannot mint to zero address");
            require(!_exists(tokenId), "Token already exists");

            transferFrom(address(0), to, tokenId);

            emit Transfer(address(0), to, tokenId);
        }

Flexibility for Implementations

ERC721 implementations may include extra checks or restrictions beyond the standard, such as:

  • Pausable transfers

  • Blacklisted addresses

  • Transaction fees

  • Other custom logic

  • Unsuccessful transactions should revert to prevent asset loss.

Important: As with ERC20, always use battle-tested implementations like OpenZeppelin's ERC721 contracts to ensure security and compatibility.

ERC721 Optional Extensions

  1. Metadata Extension — Provides a human-readable collection name, symbol, and tokenURI function for per-token metadata. solidity

     function name() public pure override returns (string memory) {
         return "CryptoArtCollection";
     }
    
     function symbol() public pure override returns (string memory) {
         return "CAC";
     }
    
     function tokenURI(uint256 tokenId) public view override returns (string memory) {
         require(_exists(tokenId), "Token does not exist");
         return string(abi.encodePacked("https://nft.example/api/metadata/", Strings.toString(tokenId), ".json"));
     }
    
     {
         "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."
             }
         }
     }
    
  2. Enumeration Extension

    • Allows wallets, dApps, and explorers to discover all NFTs in a contract, and to query ownership more efficiently.
    interface ERC721Enumerable {
        function totalSupply() external view returns (uint256);

        function tokenByIndex(uint256 _index) external view returns (uint256);

        function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256);
    }

ERC721 Security Issues

  1. Reentrancy attack on safeTransferFrom → Because it calls an external contract's hook, if it isn't carefully written and changes states before calling the hook, it could be vulnerable to a reentrancy attack. solidity

     function safeTransferFrom(address from, address to, uint256 tokenId) public override {
         require(_isApprovedOrOwner(msg.sender, tokenId), "Not owner");
    
         // ❌ External call happens before cleanup
         if (to.code.length > 0) {
             IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
         }
    
         transferFrom(from, to, tokenId);
     }
    
     //The safe order is:
     //Update ownership & approvals first (internal bookkeeping).
     //Then call the external hook (onERC721Received).
    
  2. Unauthorized Transfer (if access control bug) → If the check for authorization of the address is implemented incorrectly (e.g., forgetting to check both owner and approved), anyone could steal NFTs.

ERC721 Security Best Practices

  • Use only verified, audited implementations (e.g., OpenZeppelin).

  • Restrict minting and burning to authorized roles.

  • Prefer safeTransferFrom over transferFrom to prevent tokens being sent to contracts that cannot handle them.

  • Implement pausable functionality to halt the contract in case of an exploit.


Final Reflection

Throughout this deep dive into the ERC721 standard, I've gained a comprehensive understanding of how NFTs function at the protocol level and the critical considerations that make them secure and interoperable. The journey from understanding basic NFT concepts to exploring the intricate implementation details has highlighted several key insights.

First, the elegance of the ERC721 standard lies in its simplicity—by mapping unique token IDs to owners rather than tracking balances, it creates a foundation for representing truly unique digital assets. The off-chain metadata approach, while introducing some centralization concerns, provides a practical solution to blockchain storage limitations that makes NFTs economically viable.

The security implications are particularly striking. The reentrancy vulnerability in safeTransferFrom serves as a reminder that even well-intentioned safety mechanisms can introduce new attack vectors if not implemented carefully. The principle of "checks-effects-interactions" becomes crucial—always update internal state before making external calls.

From an auditing perspective, this exploration has reinforced the importance of understanding not just what the code does, but why certain patterns exist. The onERC721Received hook, for instance, prevents the common mistake of sending NFTs to contracts that can't handle them, but it also opens the door to reentrancy attacks if state changes aren't properly ordered.

Looking forward, this foundation in ERC721 will be invaluable as I continue exploring more complex NFT implementations, including newer standards like ERC1155 (multi-token standard) and emerging patterns in the NFT ecosystem. The interplay between on-chain logic and off-chain metadata will likely continue evolving, especially as storage solutions and cross-chain interoperability mature.

Most importantly, this study has reinforced the critical principle in smart contract development: security isn't just about following best practices, but about understanding the fundamental mechanisms that can fail and designing systems that gracefully handle those failures. The NFT space, with its high-value assets and complex interactions, demands this level of rigor in both development and auditing.

0
Subscribe to my newsletter

Read articles from Mosaad directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mosaad
Mosaad