ERC721Consecutive meets ERC721Enumerable: A proof of concept on Batch Minting and Token Enumeration in OpenZeppelin Contracts

Introduction

OpenZeppelin's ERC721 standard is a popular implementation of the Non-Fungible Token (NFT) standard on the Ethereum network. The ERC721 standard defines a set of functions and events that a smart contract must implement to enable the creation, ownership, and transfer of unique tokens. The standard has been extended with additional functionalities such as token enumeration and batch minting by OpenZeppelin.

One notable limitation of the ERC721 standard is that it does not provide a way to efficiently mint a consecutive series of tokens. To address this limitation, OpenZeppelin introduced the ERC721Consecutive.sol extension, which enables the minting of tokens in a consecutive sequence.

On the other hand, ERC721Enumerable.sol allows for efficient enumeration of a token owner's tokens. It adds functions to retrieve the total supply of tokens, the token at a specific index, and a list of all tokens owned by a particular address. This enables developers to build more advanced applications that require tracking and managing the ownership tokens.

However, the two extensions are not compatible with each other due to lack of tracking mechanisms for batch minted tokens. In this article, I will present an extension that combines the features of ERC721Consecutive.sol and ERC721Enumerable.sol and demonstrates their compatibility.

Implementation summary

In this article, I will provide explanation just for implementation of tokenOfOwnerByIndex(address owner, uint256 index):

The implementation combines the batch minting feature of ERC721Consecutive.sol with the token enumeration feature of ERC721Enumerable.sol. When tokens are batch minted, their indices are not initialized in the _ownedTokens mapping as it is done in ERC721Enumerable.sol. To solve this, the implementation uses lazy indexing, where it considers all of the token indices sequentially from their first tokenId that was batch minted.

When a token is transferred, the _ownedTokens and _ownedTokensIndex mappings are populated with a swap operation, if necessary, to keep track of the tokens owned by a particular address. If a token index is not initialized (i.e., the _ownedTokens[owner][index] is 0), we can access it virtually by subtracting the first tokenId of the batch from the tokenId.

To remove the ambiguity between the value uint(0) and the default value of the mapping, which is also uint(0), the implementation adds 1 to the values when populating the _ownedTokens and _ownedTokensIndex mappings, and subtracts 1 when accessing them.

Overall, the implementation provides a way to efficiently batch mint tokens while allowing for easy enumeration and querying of token ownership with O(1) complexity. It is also compatible with OpenZeppelin ERC721.sol contracts and can be used to implement a wide range of applications in the blockchain space.


 // Mapping from owner to list of owned token IDs (some values are available virtually to access use _ownerTokenByIndex(_owner,_index) )
mapping(address => mapping(uint256 => uint256)) private _ownedTokens;

    // Mapping from token ID to index of the owner tokens list(some values are available virtualy to access use _ownerIndexByToken(_tokenId))
    mapping(uint256 => uint256) private _ownedTokensIndex;

////-------starting tokenId for batch minters , populated by _mintConsecutive(address to, uint96 batchSize)
    mapping(address => uint256) private _ownerStartTokenId;


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

/**
     *
     *
     * @dev See {ERC721-_beforeTokenTransfer}, Hook that is called before any token transfer. This includes minting
     * and burning.
     *
     * hook modified for consecutive transfer while maintaning enumarability
     */

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 tokenId,
        uint256 batchSize
    ) internal virtual override {
        super._beforeTokenTransfer(from, to, tokenId, batchSize);

        //enumeration operations does not triger during batch minting and instead will be handled virtualy.
        if (batchSize > 1) {
            require(
                !Address.isContract(address(this)),
                "batch minting is restricted to constructor"
            );
        } else {
            if (from == address(0)) {
                _addTokenToAllTokensEnumeration(tokenId);
            } else if (from != to) {
                _removeTokenFromOwnerEnumeration(from, tokenId);
            }
            if (to == address(0)) {
                _removeTokenFromAllTokensEnumeration(tokenId);
            } else if (to != from) {
                _addTokenToOwnerEnumeration(to, tokenId);
            }
        }
    }

 /**
     * @dev Private function to add a token to this extension's ownership-tracking data structures.
     * @param to address representing the new owner of the given token ID
     * @param tokenId uint256 ID of the token to be added to the tokens list of the given address
     * write values + 1 to avoid confussion to mapping default value of uint (uint(0))
     */
    function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private {
        uint256 length = ERC721.balanceOf(to);
        // + 1 to remove the ambiguity of value with default value(uint 0) in mapping of  _ownedTokens and _ownedTokensIndex
        _ownedTokens[to][length] = tokenId + 1;
        _ownedTokensIndex[tokenId] = length + 1;
    }

    /**
     * @dev Private function to remove a token from this extension's ownership-tracking data structures. Note that
     * while the token is not assigned a new owner, the `_ownedTokensIndex` mapping is _not_ updated: this allows for
     * gas optimizations e.g. when performing a transfer operation (avoiding double writes).
     * This has O(1) time complexity, but alters the order of the _ownedTokens array.
     * @param from address representing the previous owner of the given token ID
     * @param tokenId uint256 ID of the token to be removed from the tokens list of the given address
     * write values + 1 to avoid confussion to mapping default value of uint (uint(0))
     */
    function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId)
        private
    {
        // To prevent a gap in from's tokens array, we store the last token in the index of the token to delete, and
        // then delete the last slot (swap and pop).

        uint256 lastTokenIndex = ERC721.balanceOf(from) - 1;
        // uint256 tokenIndex = _ownedTokensIndex[tokenId];
        uint256 tokenIndex = _ownerIndexByToken(tokenId);

        // When the token to delete is the last token, the swap operation is unnecessary
        if (tokenIndex != lastTokenIndex) {
            // uint256 lastTokenId = _ownedTokens[from][lastTokenIndex];
            uint256 lastTokenId = _ownerTokenByIndex(from, lastTokenIndex); //[from][lastTokenIndex];
            // + 1 to remove the ambiguity of value with default value(uint 0) in mapping of  _ownedTokens and _ownedTokensIndex
            _ownedTokens[from][tokenIndex] = lastTokenId + 1; // Move the last token to the slot of the to-delete token
            _ownedTokensIndex[lastTokenId] = tokenIndex + 1; // Update the moved token's index
        }

        // This also deletes the contents at the last position of the array
        delete _ownedTokensIndex[tokenId];
        delete _ownedTokens[from][lastTokenIndex];
    }

//like tokenOfOwnerByIndex but does NOT revert
    /**
     * @dev See {IERC721Enumerable-tokenOfOwnerByIndex}.
     */
    function _ownerTokenByIndex(address owner, uint256 index)
        private
        view
        returns (uint256)
    {
        uint256 virtual_tokenId = _ownedTokens[owner][index];
        //if there is noting is stored in the mapping, consider tokenId sequentialy from _ownerStartTokenId[owner]
        if (virtual_tokenId == 0) {
            return index + _ownerStartTokenId[owner]; //new
        } else {
            return virtual_tokenId - 1; //decrement one (-1) to get the value,overflow is impossible becuase the virtual_tokenId is not 0.
        }
    }

    //finding the index of a token in tokens list that owned by the owner
    function _ownerIndexByToken(uint256 tokenId)
        private
        view
        returns (uint256)
    {
        //if there is noting is stored in the mapping, consider index sequentialy from _ownerStartTokenId[_owner]
        uint256 virtual_index = _ownedTokensIndex[tokenId];
        if (virtual_index == 0) {
            address _owner = _ownerOf(tokenId);
            return tokenId - _ownerStartTokenId[_owner];
        } else {
            return virtual_index - 1; //decrement one (-1) to get the value, overflow is impossible because the virtual_Index is not 0.
        }
    }

Compatibility Test

To test the integrity of the combined implementation, I created two contracts: one implemented using the new extension and another using OpenZeppelin's ERC721Enumerable.sol extension. I initialized both contracts with a random number of tokens and minted the same number of tokens with OpenZeppelin's ERC721Enumerable.sol extension. I then randomly transferred tokens between different addresses, randomly minted and burned tokens, and compared the two implementations to identify any differences.

The tests revealed that the two implementations behaved similarly, and there were no significant differences in their performance. The combined implementation not only allows for consecutive minting but also enables token enumeration and indexing functions with O(1) complexity.

you can see the report here: report

Conclusion

This extension allows users to perform batch minting of multiple tokens to different addresses, as well as single minting and single burning of tokens. Additionally, the extension has full compatibility with IERC721Enumerable, and all indexing functions have a time complexity of O(1).

In this article, I presented an extension that combines the features of ERC721Consecutive.sol and ERC721Enumerable.sol and demonstrates their compatibility. The tests conducted to compare the combined implementation with OpenZeppelin's ERC721Enumerable.sol extension revealed no significant differences, confirming the extension's integrity.

I invite developers to check out the implementation on GitHub at ERC721ConsecutiveEnumerable and explore its features and capabilities. I encourage everyone to join the conversation on the compatibility of ERC721Consecutive with ERC721Enumerable by providing feedback on the issue I opened on OpenZeppelin's GitHub repository at Issue #3985. Your input and contributions can help improve this proof of concept and support the development of NFT-based applications

Instead of

is there a vulnerability on the 2nd method that i'm missing?

References

4 Likes