ERC721Enumerable gas profiling and optimizations

Was curious about gas costs and am wondering if it's possible to optimize ERC721Enumerable when using typical 10K collectible structure of Pausable+Enumerable+AutoIncrementID.

Initial profiling showed base OpenZeppelin minting costs about 70,000 gas and typical collectible about 150,000. Big jumps in keeping track of tokenIdCounter and adding Enumerable.

ERC721Enumerable creates storage structures for the three implementation functions.
totalSupply
tokenByIndex
tokenOfOwnerByIndex

In the specific case of auto-incrementing IDs (non-burnable), totalSupply and tokenByIndex could be calculated directly from tokenIdCounter as it's an ordered counter.

function totalSupply() public view virtual override returns (uint256) {
    return _tokenIdCounter.current();
}

function tokenByIndex(uint256 index) public view virtual override returns (uint256) {
    require(index < totalSupply(), "ERC721Enumerable: global index out of bounds");
    return index;
}

Optimizing just these functions and profiling yields about 30% gas savings over robust enumerable implementation.

Curious if there's any issues with these optimizations and to see what others think. Thanks!

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.7;

import "./ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol";

/**
 * @dev This implements an optional extension of {ERC721} defined in the EIP that adds
 * enumerability of all the token ids in the contract as well as all token ids owned by each
 * account but rips out the core of the gas-wasting processing that comes from OpenZeppelin.
 */
abstract contract ERC721Enumerable is ERC721, IERC721Enumerable {
    /**
     * @dev See {IERC165-supportsInterface}.
     */
    function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC721) returns (bool) {
        return interfaceId == type(IERC721Enumerable).interfaceId || super.supportsInterface(interfaceId);
    }

    /**
     * @dev See {IERC721Enumerable-totalSupply}.
     */
    function totalSupply() public view virtual override returns (uint256) {
        return _owners.length;
    }

    /**
     * @dev See {IERC721Enumerable-tokenByIndex}.
     */
    function tokenByIndex(uint256 index) public view virtual override returns (uint256) {
        require(index < _owners.length, "ERC721Enumerable: global index out of bounds");
        return index;
    }

    /**
     * @dev See {IERC721Enumerable-tokenOfOwnerByIndex}.
     */
    function tokenOfOwnerByIndex(address owner, uint256 index) public view virtual override returns (uint256 tokenId) {
        require(index < balanceOf(owner), "ERC721Enumerable: owner index out of bounds");

        uint count;
        for(uint i; i < _owners.length; i++){
            if(owner == _owners[i]){
                if(count == index) return i;
                else count++;
            }
        }

        revert("ERC721Enumerable: owner index out of bounds");
    }
}

The NFT project NuclearNerds just did something very similar and now I am also wondering if there is any issue with this approach. At first sight it looks legit to me, but there must be a reason OZ added the three mappings. Any opinions on this?

Hello @Chriso

This extension, used by NuclearNerds, is not compatible with OpenZeppelin's ERC721 core contract. They modified both the ERC721 core and the Enumerability extensions.

Their approach is VERY restrictive:

  • tokens must be minted sequentially
  • tokens cannot be burned
  • the functions balanceOf and tokenOfOwnerByIndex need to loop over the entire supply, which make them very impossible to rely on → this will break interoperability with other contracts.

Our implementation of ERC721Enumerable is expensive to use because it keeps track of complex data without making any assumptions about the way tokens are minted (sequentially or not) and burned.

Also, note that including ERC721Enumerable is not an ERC requirement. If you are building one of these "10000 tokens" contracts, I could argue that you should NOT use ERC721Enumerable. You can have a custom totalSupply() function that returns a fix value.

tokenByIndex doesn't really make that much sense when tokens are sequentially minted and not burned (it can be trivially reimplemented). You just have to ask yourself if you are going to miss tokenOfOwnerByIndex.

2 Likes

Hi @Amxx could you help me understand what sort of problems looping over the entire supply in view functions balanceof and tokenOfOwnerByIndex cause? You mentioned interoperability with other contracts - can you point me to some examples/documentation?