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?

Just imagine a contract that allows you to vote with your NFTs (or something similar). In order to know how many votes you have, it needs to know how many tokens you have, which is done by calling balanceOf.

If the balance is stored in a dedicated storage slot, you can just read it: its 2100 gas and you're done.

But if the balance needs to be recomputed, you have to check the ownership of ALL the existing tokens on that contract, and verify if it's counting toward the balance of the account we are considering. Now let's imagine there are 10k NFTs on this contract (this appears to be a quite common number). You'll have to check 10k owners, this means reading 10k slot ... at 2100gas a piece, that is 21million gas, just to read the balance (not even counting the extra gas it cost to just run the loop).

I hope you understand that while it's reasonable to have a UI do an RPC call to get the result without paying gas, a smart contract (like our example voting contract) cannot reasonably rely on that.

Now imagine something that is Uniswap-like for ERC721. It should support all ERC721 (just like uniswap should support ERC20) ... even though they are deployed by third parties. Well, if that contract starts calling a 21m gas balanceOf, it's not going to work, and the consequences could be really bad.

I don't understand why there is gas fee, if it's a view function ?

There is no gas fee if a view function is called by an external observer (for example if you want to check your balance).

But you still have to pay gas fee if a view function is called in the context of a (state changing) transaction.

For example, if you have this function

function transferAllToOwner(IERC20 token, address recipient) external {
    uint256 balance = token.balanceOf(address(this));
    uint256 to = owner();
    token.transfer(to, balance);
}

Then you will have to pay gas for the calls to token.balanceOf() and to owner().
These cost are not insignificant. And if for any reason one of them was to consume to much gas (because of a loop?), then the function might become unusable.

1 Like