Hi everyone!
I'm trying to work through the most efficient way to solve the following problem:
Imagine we have an ERC721 with 1000 tokens, ids 0 - 999. On every mint, the caller gets one of the IDs that is remaining, at random. How should we store which ID's are still available?
The following is a POC that I think should work, but would love to hear some other options that I might be missing.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
// External
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
contract RandomMint is ERC721 {
using Math for *;
/**
* @dev Represents up to 256 id's
*
* @dev A `maxSupply` T will have T / 256 bitmappings, representing all of the different id's possible.
*
* @dev This structure allows us to randomize and keep track of which id's have been minted
*
* {uint256} originalPosition - the original position in the index of the array of bitmaps, providing the offset for the id
* {uint256} bitmap - the actual bitmap holding up to 256 unique ids
* {uint256} maxBitPosition - the maximum bit position of this bitmap, which allows us to determine when the bitmap is full
*/
struct BitMapping {
uint256 originalPosition;
uint256 bitmap;
uint256 maxBitPosition;
}
/**
* @dev Map indexes to BitMaps
*
* @dev Used to track the remaining id's
*/
BitMapping[] public bitMappings;
constructor(uint256 maxSupply) ERC721("RandomMint", "RM") {
/*
* Determine the number of bit mappings to make based off of the `maxSupply`
*
* Each BitMapping can hold 256 ids, with each id represented by a bit.
* Therefore, he amount will of BitMappings for this location is calculated as:
* `ceiling(maxSupply / 256)_`
*
* Our `originalPosition` is created by an incremental `index`,
* used to indicate what address space the bitmapping has. We can indicate the offset for a given BitMapping as:
* `bitMapping.originalPosition * (bitMapping.maxBitPosition + 1)`
*
* The `maxBitPosition` indicates how many bits of the bitmap are actually used, which
* is the minimum `255` or the supply
*/
uint256 remainingSupply = maxSupply;
uint256 index;
while (remainingSupply > 0) {
bitMappings.push(BitMapping(index++, 0, remainingSupply.min(255)));
// SEE BELOW FOR EDIT #1
remainingSupply -= remainingSupply.min(256);
}
}
/**
* @notice Mint
* @param count - The number of tokens to mint
*
* Requirements:
* - The `count` is within the range of the current price range
* - The `signer` has the `SIGNER_ROLE`
* - The proper amount was sent
*
* * On every call to `mint`, make sure to:
- generate a new random number
* On every loop, make sure to:
- update the bitmap
*/
function mint(uint256 count) external payable {
// NOTE: DO NOT USE THIS. NOT A REAL RANDOM NUMBER.
uint256 randomNumber = uint256(keccak256(abi.encode(block.timestamp)));
for (uint256 i; i < count; i++) {
// All us to reuse a random number
randomNumber = uint256(keccak256(abi.encode(randomNumber, i)));
// Get the id
(
uint256 bitmapIndex,
uint256 positionIndex
) = _getBitMapInfoForNewMint(randomNumber);
uint256 tokenId = _getTokenIdAndUpdateBitMap(
bitmapIndex,
positionIndex
);
// Actually mint the token
_mint(msg.sender, tokenId);
}
}
/**
* @dev Given a seed return a valid bitmap index and the position within it
*/
function _getBitMapInfoForNewMint(uint256 randomNumber)
internal
view
returns (uint256, uint256)
{
// Select one of the buckets that contains all available token ids
uint256 bitmapMappingIndex = randomNumber % bitMappings.length;
// Extract that bitMapping
BitMapping memory bitMapping = bitMappings[bitmapMappingIndex];
uint256 potentialTokenId;
// This represents the first index we check
uint256 originalBitMappingIndex = randomNumber %
bitMapping.maxBitPosition;
// We keep shifting the index ("walking the bitmap") until we iterate over every potential index
// SEE BELOW FOR EDIT #1
// SEE BELOW FOR EDIT #1
// SEE BELOW FOR EDIT 2
for (uint256 i; i <= bitMapping.maxBitPosition; i++) {
/*
* Offset the original selection by whatever index we're on.
* Use modulo so we can wrap around to make the range [0, bitMapping.maxBitPosition)
*/
potentialTokenId =
(originalBitMappingIndex + i) %
bitMapping.maxBitPosition;
/*
* If the result of the `bitwise AND` is zero, we know that that bit
* has not been set yet in the bitmap, indicating an available index.
*/
if (bitMapping.bitmap & (1 << potentialTokenId) == 0) {
break;
}
}
return (bitmapMappingIndex, potentialTokenId);
}
function _getTokenIdAndUpdateBitMap(
uint256 mintingIndex,
uint256 tokenPosition
) internal returns (uint256) {
BitMapping storage bitMapping = bitMappings[mintingIndex];
/*
* Update the bitmap
*
* `bit = 1 << tokenPosition`
*/
bitMapping.bitmap |= (1 << tokenPosition);
/*
* Get the actual index by taking the original offset into the bitmap array,
* multiplying it by the maxPosition of that bitmap + 1, and adding the tokenId.
*
* NOTE: Add + 1 because we WANT overflow here:
* Take: originalPosition = 1, maxBitPosition = 255, tokenPosition = 0
*
* By adding one, we ensure we end up in the range [256, X], which ensures we don't overwrite
* any index from the first bitmap.
*/
uint256 mintIndex = bitMapping.originalPosition *
(bitMapping.maxBitPosition + 1) +
tokenPosition;
/*
* If a bitmap is full, delete it from the array.
* This ensures that every `bitmap` has _at least_ one available index
*
* NOTE: Solidity "pop + swap model"
*/
if (bitMapping.bitmap == ((1 << bitMapping.maxBitPosition) - 1)) {
bitMappings[mintingIndex] = bitMappings[bitMappings.length - 1];
bitMappings.pop();
}
return mintIndex;
}
}
A few notes:
- My general arithmetic + usage of the bitmap seems sketchy at best. Am I looking at any potential one off errors? Specifically, the check at the end of
_getTokenIdAndUpdateBitMapfeels like it may not be correct. - Regarding the usage of those structs - are there advantages/disadvantages of using
memoryvsstruckvs reading from the array itself? - I believe I could pack the
BitMappingstruct itself.maxBitPositionwill allows beuint8at most, and for the use case I think we could safely assume thatoriginalPositionwill be< MAX_UINT248, so I could probably even mask those in some which way to combine into one variable (does this have pros + cons vs a tightly packed struct?).. But down the line is reading and operating on that going to then cost significant gas? - The core loop is
O(256)- is there a better data structure/arrangment ofBitMappingthat better uses EVM-specific data structuring in regards to both memory + time complexity?
EDIT #1:
I previously had the following line:
remainingSupply -= remainingSupply.min(255);
However, this proves to not be inaccurate. Take the trivial case of needing 256 tokenids. If we assume we can use tokenId == 0, then 0 - 255 already provides with 256 valid tokenIds, meaning we should actually decrease supply by 256.
However, the max maxBitPosition still should be < 256, as it also includes 0 as a valid index, meaning it represents maxBitPosition + 1 slots.
EDIT #2:
I previous had the following line:
for (uint256 i; i < bitMapping.maxBitPosition; i++) {
The need for the change is partly explaing in EDIT #1 - given that maxBitPosition represents maxBitPosition + 1 valid indices, we actually need to iterate through that index as well, hence the <=.