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?