From cf0108da445038cb424630310d87657ef438d37e Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 24 Feb 2024 17:47:31 +0000 Subject: [PATCH] Transpile chiru-labs/ERC721A@b3517b062cfe00d032ff787983a89f3fa790d1ec --- contracts/ERC721AStorage.sol | 3 + contracts/ERC721AUpgradeable.sol | 186 +++++++++- contracts/IERC721AUpgradeable.sol | 25 ++ .../ERC721AQueryableUpgradeable.sol | 55 +-- .../mocks/ERC721ASpotMockUpgradeable.sol | 96 ++++++ .../mocks/SequentialUpToHelperUpgradeable.sol | 38 +++ contracts/mocks/WithInit.sol | 29 +- test/extensions/ERC721ASpot.test.js | 319 ++++++++++++++++++ 8 files changed, 710 insertions(+), 41 deletions(-) create mode 100644 contracts/mocks/ERC721ASpotMockUpgradeable.sol create mode 100644 contracts/mocks/SequentialUpToHelperUpgradeable.sol create mode 100644 test/extensions/ERC721ASpot.test.js diff --git a/contracts/ERC721AStorage.sol b/contracts/ERC721AStorage.sol index a964a15..77cf385 100644 --- a/contracts/ERC721AStorage.sol +++ b/contracts/ERC721AStorage.sol @@ -44,6 +44,9 @@ library ERC721AStorage { mapping(uint256 => ERC721AStorage.TokenApprovalRef) _tokenApprovals; // Mapping from owner to operator approvals mapping(address => mapping(address => bool)) _operatorApprovals; + // The amount of tokens minted above `_sequentialUpTo()`. + // We call these spot mints (i.e. non-sequential mints). + uint256 _spotMinted; } bytes32 internal constant STORAGE_SLOT = keccak256('ERC721A.contracts.storage.ERC721A'); diff --git a/contracts/ERC721AUpgradeable.sol b/contracts/ERC721AUpgradeable.sol index fc55ad9..32405e5 100644 --- a/contracts/ERC721AUpgradeable.sol +++ b/contracts/ERC721AUpgradeable.sol @@ -30,6 +30,9 @@ interface ERC721A__IERC721ReceiverUpgradeable { * Token IDs are minted in sequential order (e.g. 0, 1, 2, 3, ...) * starting from `_startTokenId()`. * + * The `_sequentialUpTo()` function can be overriden to enable spot mints + * (i.e. non-consecutive mints) for `tokenId`s greater than `_sequentialUpTo()`. + * * Assumptions: * * - An owner cannot have more than 2**64 - 1 (max value of uint64) of supply. @@ -101,6 +104,8 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { ERC721AStorage.layout()._name = name_; ERC721AStorage.layout()._symbol = symbol_; ERC721AStorage.layout()._currentIndex = _startTokenId(); + + if (_sequentialUpTo() < _startTokenId()) _revert(SequentialUpToTooSmall.selector); } // ============================================================= @@ -108,13 +113,28 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { // ============================================================= /** - * @dev Returns the starting token ID. - * To change the starting token ID, please override this function. + * @dev Returns the starting token ID for sequential mints. + * + * Override this function to change the starting token ID for sequential mints. + * + * Note: The value returned must never change after any tokens have been minted. */ function _startTokenId() internal view virtual returns (uint256) { return 0; } + /** + * @dev Returns the maximum token ID (inclusive) for sequential mints. + * + * Override this function to return a value less than 2**256 - 1, + * but greater than `_startTokenId()`, to enable spot (non-sequential) mints. + * + * Note: The value returned must never change after any tokens have been minted. + */ + function _sequentialUpTo() internal view virtual returns (uint256) { + return type(uint256).max; + } + /** * @dev Returns the next token ID to be minted. */ @@ -127,22 +147,26 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { * Burned tokens will reduce the count. * To get the total number of tokens minted, please see {_totalMinted}. */ - function totalSupply() public view virtual override returns (uint256) { - // Counter underflow is impossible as _burnCounter cannot be incremented - // more than `_currentIndex - _startTokenId()` times. + function totalSupply() public view virtual override returns (uint256 result) { + // Counter underflow is impossible as `_burnCounter` cannot be incremented + // more than `_currentIndex + _spotMinted - _startTokenId()` times. unchecked { - return ERC721AStorage.layout()._currentIndex - ERC721AStorage.layout()._burnCounter - _startTokenId(); + // With spot minting, the intermediate `result` can be temporarily negative, + // and the computation must be unchecked. + result = ERC721AStorage.layout()._currentIndex - ERC721AStorage.layout()._burnCounter - _startTokenId(); + if (_sequentialUpTo() != type(uint256).max) result += ERC721AStorage.layout()._spotMinted; } } /** * @dev Returns the total amount of tokens minted in the contract. */ - function _totalMinted() internal view virtual returns (uint256) { + function _totalMinted() internal view virtual returns (uint256 result) { // Counter underflow is impossible as `_currentIndex` does not decrement, // and it is initialized to `_startTokenId()`. unchecked { - return ERC721AStorage.layout()._currentIndex - _startTokenId(); + result = ERC721AStorage.layout()._currentIndex - _startTokenId(); + if (_sequentialUpTo() != type(uint256).max) result += ERC721AStorage.layout()._spotMinted; } } @@ -153,6 +177,13 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { return ERC721AStorage.layout()._burnCounter; } + /** + * @dev Returns the total number of tokens that are spot-minted. + */ + function _totalSpotMinted() internal view virtual returns (uint256) { + return ERC721AStorage.layout()._spotMinted; + } + // ============================================================= // ADDRESS DATA OPERATIONS // ============================================================= @@ -311,11 +342,17 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { } /** - * Returns the packed ownership data of `tokenId`. + * @dev Returns the packed ownership data of `tokenId`. */ function _packedOwnershipOf(uint256 tokenId) private view returns (uint256 packed) { if (_startTokenId() <= tokenId) { packed = ERC721AStorage.layout()._packedOwnerships[tokenId]; + + if (tokenId > _sequentialUpTo()) { + if (_packedOwnershipExists(packed)) return packed; + _revert(OwnerQueryForNonexistentToken.selector); + } + // If the data at the starting slot does not exist, start the scan. if (packed == 0) { if (tokenId >= ERC721AStorage.layout()._currentIndex) _revert(OwnerQueryForNonexistentToken.selector); @@ -444,6 +481,9 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { */ function _exists(uint256 tokenId) internal view virtual returns (bool result) { if (_startTokenId() <= tokenId) { + if (tokenId > _sequentialUpTo()) + return _packedOwnershipExists(ERC721AStorage.layout()._packedOwnerships[tokenId]); + if (tokenId < ERC721AStorage.layout()._currentIndex) { uint256 packed; while ((packed = ERC721AStorage.layout()._packedOwnerships[tokenId]) == 0) --tokenId; @@ -452,6 +492,17 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { } } + /** + * @dev Returns whether `packed` represents a token that exists. + */ + function _packedOwnershipExists(uint256 packed) private pure returns (bool result) { + assembly { + // The following is equivalent to `owner != address(0) && burned == false`. + // Symbolically tested. + result := gt(and(packed, _BITMASK_ADDRESS), and(packed, _BITMASK_BURNED)) + } + } + /** * @dev Returns whether `msgSender` is equal to `approvedAddress` or `owner`. */ @@ -745,6 +796,8 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { uint256 end = startTokenId + quantity; uint256 tokenId = startTokenId; + if (end - 1 > _sequentialUpTo()) _revert(SequentialMintExceedsLimit.selector); + do { assembly { // Emit the `Transfer` event. @@ -814,6 +867,8 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { _nextInitializedFlag(quantity) | _nextExtraData(address(0), to, 0) ); + if (startTokenId + quantity - 1 > _sequentialUpTo()) _revert(SequentialMintExceedsLimit.selector); + emit ConsecutiveTransfer(startTokenId, startTokenId + quantity - 1, address(0), to); ERC721AStorage.layout()._currentIndex = startTokenId + quantity; @@ -850,8 +905,9 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { _revert(TransferToNonERC721ReceiverImplementer.selector); } } while (index < end); - // Reentrancy protection. - if (ERC721AStorage.layout()._currentIndex != end) _revert(bytes4(0)); + // This prevents reentrancy to `_safeMint`. + // It does not prevent reentrancy to `_safeMintSpot`. + if (ERC721AStorage.layout()._currentIndex != end) revert(); } } } @@ -863,6 +919,112 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { _safeMint(to, quantity, ''); } + /** + * @dev Mints a single token at `tokenId`. + * + * Note: A spot-minted `tokenId` that has been burned can be re-minted again. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` must be greater than `_sequentialUpTo()`. + * - `tokenId` must not exist. + * + * Emits a {Transfer} event for each mint. + */ + function _mintSpot(address to, uint256 tokenId) internal virtual { + if (tokenId <= _sequentialUpTo()) _revert(SpotMintTokenIdTooSmall.selector); + uint256 prevOwnershipPacked = ERC721AStorage.layout()._packedOwnerships[tokenId]; + if (_packedOwnershipExists(prevOwnershipPacked)) _revert(TokenAlreadyExists.selector); + + _beforeTokenTransfers(address(0), to, tokenId, 1); + + // Overflows are incredibly unrealistic. + // The `numberMinted` for `to` is incremented by 1, and has a max limit of 2**64 - 1. + // `_spotMinted` is incremented by 1, and has a max limit of 2**256 - 1. + unchecked { + // Updates: + // - `address` to the owner. + // - `startTimestamp` to the timestamp of minting. + // - `burned` to `false`. + // - `nextInitialized` to `true` (as `quantity == 1`). + ERC721AStorage.layout()._packedOwnerships[tokenId] = _packOwnershipData( + to, + _nextInitializedFlag(1) | _nextExtraData(address(0), to, prevOwnershipPacked) + ); + + // Updates: + // - `balance += 1`. + // - `numberMinted += 1`. + // + // We can directly add to the `balance` and `numberMinted`. + ERC721AStorage.layout()._packedAddressData[to] += (1 << _BITPOS_NUMBER_MINTED) | 1; + + // Mask `to` to the lower 160 bits, in case the upper bits somehow aren't clean. + uint256 toMasked = uint256(uint160(to)) & _BITMASK_ADDRESS; + + if (toMasked == 0) _revert(MintToZeroAddress.selector); + + assembly { + // Emit the `Transfer` event. + log4( + 0, // Start of data (0, since no data). + 0, // End of data (0, since no data). + _TRANSFER_EVENT_SIGNATURE, // Signature. + 0, // `address(0)`. + toMasked, // `to`. + tokenId // `tokenId`. + ) + } + + ++ERC721AStorage.layout()._spotMinted; + } + + _afterTokenTransfers(address(0), to, tokenId, 1); + } + + /** + * @dev Safely mints a single token at `tokenId`. + * + * Note: A spot-minted `tokenId` that has been burned can be re-minted again. + * + * Requirements: + * + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}. + * - `tokenId` must be greater than `_sequentialUpTo()`. + * - `tokenId` must not exist. + * + * See {_mintSpot}. + * + * Emits a {Transfer} event. + */ + function _safeMintSpot( + address to, + uint256 tokenId, + bytes memory _data + ) internal virtual { + _mintSpot(to, tokenId); + + unchecked { + if (to.code.length != 0) { + uint256 currentSpotMinted = ERC721AStorage.layout()._spotMinted; + if (!_checkContractOnERC721Received(address(0), to, tokenId, _data)) { + _revert(TransferToNonERC721ReceiverImplementer.selector); + } + // This prevents reentrancy to `_safeMintSpot`. + // It does not prevent reentrancy to `_safeMint`. + if (ERC721AStorage.layout()._spotMinted != currentSpotMinted) revert(); + } + } + } + + /** + * @dev Equivalent to `_safeMintSpot(to, tokenId, '')`. + */ + function _safeMintSpot(address to, uint256 tokenId) internal virtual { + _safeMintSpot(to, tokenId, ''); + } + // ============================================================= // APPROVAL OPERATIONS // ============================================================= @@ -986,7 +1148,7 @@ contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { emit Transfer(from, address(0), tokenId); _afterTokenTransfers(from, address(0), tokenId, 1); - // Overflow not possible, as _burnCounter cannot be exceed _currentIndex times. + // Overflow not possible, as `_burnCounter` cannot be exceed `_currentIndex + _spotMinted` times. unchecked { ERC721AStorage.layout()._burnCounter++; } diff --git a/contracts/IERC721AUpgradeable.sol b/contracts/IERC721AUpgradeable.sol index 15058be..5868262 100644 --- a/contracts/IERC721AUpgradeable.sol +++ b/contracts/IERC721AUpgradeable.sol @@ -74,6 +74,31 @@ interface IERC721AUpgradeable { */ error OwnershipNotInitializedForExtraData(); + /** + * `_sequentialUpTo()` must be greater than `_startTokenId()`. + */ + error SequentialUpToTooSmall(); + + /** + * The `tokenId` of a sequential mint exceeds `_sequentialUpTo()`. + */ + error SequentialMintExceedsLimit(); + + /** + * Spot minting requires a `tokenId` greater than `_sequentialUpTo()`. + */ + error SpotMintTokenIdTooSmall(); + + /** + * Cannot mint over a token that already exists. + */ + error TokenAlreadyExists(); + + /** + * The feature is not compatible with spot mints. + */ + error NotCompatibleWithSpotMints(); + // ============================================================= // STRUCTS // ============================================================= diff --git a/contracts/extensions/ERC721AQueryableUpgradeable.sol b/contracts/extensions/ERC721AQueryableUpgradeable.sol index e72561c..de77153 100644 --- a/contracts/extensions/ERC721AQueryableUpgradeable.sol +++ b/contracts/extensions/ERC721AQueryableUpgradeable.sol @@ -57,6 +57,8 @@ abstract contract ERC721AQueryableUpgradeable is { unchecked { if (tokenId >= _startTokenId()) { + if (tokenId > _sequentialUpTo()) return _ownershipAt(tokenId); + if (tokenId < _nextTokenId()) { // If the `tokenId` is within bounds, // scan backwards for the initialized ownership slot. @@ -136,6 +138,8 @@ abstract contract ERC721AQueryableUpgradeable is * an out-of-gas error (10K collections should be fine). */ function tokensOfOwner(address owner) external view virtual override returns (uint256[] memory) { + // If spot mints are enabled, full-range scan is disabled. + if (_sequentialUpTo() != type(uint256).max) _revert(NotCompatibleWithSpotMints.selector); uint256 start = _startTokenId(); uint256 stop = _nextTokenId(); uint256[] memory tokenIds; @@ -153,37 +157,32 @@ abstract contract ERC721AQueryableUpgradeable is address owner, uint256 start, uint256 stop - ) private view returns (uint256[] memory) { + ) private view returns (uint256[] memory tokenIds) { unchecked { if (start >= stop) _revert(InvalidQueryRange.selector); // Set `start = max(start, _startTokenId())`. - if (start < _startTokenId()) { - start = _startTokenId(); - } - uint256 stopLimit = _nextTokenId(); + if (start < _startTokenId()) start = _startTokenId(); + uint256 nextTokenId = _nextTokenId(); + // If spot mints are enabled, scan all the way until the specified `stop`. + uint256 stopLimit = _sequentialUpTo() != type(uint256).max ? stop : nextTokenId; // Set `stop = min(stop, stopLimit)`. - if (stop >= stopLimit) { - stop = stopLimit; - } - uint256[] memory tokenIds; + if (stop >= stopLimit) stop = stopLimit; + // Number of tokens to scan. uint256 tokenIdsMaxLength = balanceOf(owner); - bool startLtStop = start < stop; - assembly { - // Set `tokenIdsMaxLength` to zero if `start` is less than `stop`. - tokenIdsMaxLength := mul(tokenIdsMaxLength, startLtStop) - } + // Set `tokenIdsMaxLength` to zero if the range contains no tokens. + if (start >= stop) tokenIdsMaxLength = 0; + // If there are one or more tokens to scan. if (tokenIdsMaxLength != 0) { - // Set `tokenIdsMaxLength = min(balanceOf(owner), stop - start)`, - // to cater for cases where `balanceOf(owner)` is too big. - if (stop - start <= tokenIdsMaxLength) { - tokenIdsMaxLength = stop - start; - } + // Set `tokenIdsMaxLength = min(balanceOf(owner), tokenIdsMaxLength)`. + if (stop - start <= tokenIdsMaxLength) tokenIdsMaxLength = stop - start; + uint256 m; // Start of available memory. assembly { // Grab the free memory pointer. tokenIds := mload(0x40) // Allocate one word for the length, and `tokenIdsMaxLength` words // for the data. `shl(5, x)` is equivalent to `mul(32, x)`. - mstore(0x40, add(tokenIds, shl(5, add(tokenIdsMaxLength, 1)))) + m := add(tokenIds, shl(5, add(tokenIdsMaxLength, 1))) + mstore(0x40, m) } // We need to call `explicitOwnershipOf(start)`, // because the slot at `start` may not be initialized. @@ -193,14 +192,18 @@ abstract contract ERC721AQueryableUpgradeable is // initialize `currOwnershipAddr`. // `ownership.address` will not be zero, // as `start` is clamped to the valid token ID range. - if (!ownership.burned) { - currOwnershipAddr = ownership.addr; - } + if (!ownership.burned) currOwnershipAddr = ownership.addr; uint256 tokenIdsIdx; // Use a do-while, which is slightly more efficient for this case, // as the array will at least contain one element. do { - ownership = _ownershipAt(start); + if (_sequentialUpTo() != type(uint256).max) { + // Skip the remaining unused sequential slots. + if (start == nextTokenId) start = _sequentialUpTo() + 1; + // Reset `currOwnershipAddr`, as each spot-minted token is a batch of one. + if (start > _sequentialUpTo()) currOwnershipAddr = address(0); + } + ownership = _ownershipAt(start); // This implicitly allocates memory. assembly { switch mload(add(ownership, 0x40)) // if `ownership.burned == false`. @@ -226,6 +229,9 @@ abstract contract ERC721AQueryableUpgradeable is currOwnershipAddr := 0 } start := add(start, 1) + // Free temporary memory implicitly allocated for ownership + // to avoid quadratic memory expansion costs. + mstore(0x40, m) } } while (!(start == stop || tokenIdsIdx == tokenIdsMaxLength)); // Store the length of the array. @@ -233,7 +239,6 @@ abstract contract ERC721AQueryableUpgradeable is mstore(tokenIds, tokenIdsIdx) } } - return tokenIds; } } } diff --git a/contracts/mocks/ERC721ASpotMockUpgradeable.sol b/contracts/mocks/ERC721ASpotMockUpgradeable.sol new file mode 100644 index 0000000..ca25a69 --- /dev/null +++ b/contracts/mocks/ERC721ASpotMockUpgradeable.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creators: Chiru Labs + +pragma solidity ^0.8.4; + +import './ERC721AQueryableMockUpgradeable.sol'; +import './StartTokenIdHelperUpgradeable.sol'; +import './SequentialUpToHelperUpgradeable.sol'; +import '../ERC721A__Initializable.sol'; + +contract ERC721ASpotMockUpgradeable is + ERC721A__Initializable, + StartTokenIdHelperUpgradeable, + SequentialUpToHelperUpgradeable, + ERC721AQueryableMockUpgradeable +{ + function __ERC721ASpotMock_init( + string memory name_, + string memory symbol_, + uint256 startTokenId_, + uint256 sequentialUpTo_, + uint256 quantity, + bool mintInConstructor + ) internal onlyInitializingERC721A { + __StartTokenIdHelper_init_unchained(startTokenId_); + __SequentialUpToHelper_init_unchained(sequentialUpTo_); + __ERC721A_init_unchained(name_, symbol_); + __ERC721AQueryable_init_unchained(); + __ERC721ABurnable_init_unchained(); + __DirectBurnBitSetterHelper_init_unchained(); + __ERC721AQueryableMock_init_unchained(name_, symbol_); + __ERC721ASpotMock_init_unchained(name_, symbol_, startTokenId_, sequentialUpTo_, quantity, mintInConstructor); + } + + function __ERC721ASpotMock_init_unchained( + string memory, + string memory, + uint256, + uint256, + uint256 quantity, + bool mintInConstructor + ) internal onlyInitializingERC721A { + if (mintInConstructor) { + _mintERC2309(msg.sender, quantity); + } + } + + function _startTokenId() internal view override returns (uint256) { + return startTokenId(); + } + + function _sequentialUpTo() internal view override returns (uint256) { + return sequentialUpTo(); + } + + function exists(uint256 tokenId) public view returns (bool) { + return _exists(tokenId); + } + + function getOwnershipOf(uint256 index) public view returns (TokenOwnership memory) { + return _ownershipOf(index); + } + + function safeMintSpot(address to, uint256 tokenId) public { + _safeMintSpot(to, tokenId); + } + + function totalSpotMinted() public view returns (uint256) { + return _totalSpotMinted(); + } + + function totalMinted() public view returns (uint256) { + return _totalMinted(); + } + + function totalBurned() public view returns (uint256) { + return _totalBurned(); + } + + function numberBurned(address owner) public view returns (uint256) { + return _numberBurned(owner); + } + + function setExtraDataAt(uint256 tokenId, uint24 value) public { + _setExtraDataAt(tokenId, value); + } + + function _extraData( + address, + address, + uint24 previousExtraData + ) internal view virtual override returns (uint24) { + return previousExtraData; + } +} diff --git a/contracts/mocks/SequentialUpToHelperUpgradeable.sol b/contracts/mocks/SequentialUpToHelperUpgradeable.sol new file mode 100644 index 0000000..d1a8a17 --- /dev/null +++ b/contracts/mocks/SequentialUpToHelperUpgradeable.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creators: Chiru Labs + +pragma solidity ^0.8.4; +import '../ERC721A__Initializable.sol'; + +/** + * This Helper is used to return a dynamic value in the overridden _sequentialUpTo() function. + * Extending this Helper before the ERC721A contract give us access to the herein set `sequentialUpTo` + * to be returned by the overridden `_sequentialUpTo()` function of ERC721A in the ERC721ASpot mocks. + */ +contract SequentialUpToHelperUpgradeable is ERC721A__Initializable { + // `bytes4(keccak256('sequentialUpTo'))`. + uint256 private constant SEQUENTIAL_UP_TO_STORAGE_SLOT = 0x9638c59e; + + function __SequentialUpToHelper_init(uint256 sequentialUpTo_) internal onlyInitializingERC721A { + __SequentialUpToHelper_init_unchained(sequentialUpTo_); + } + + function __SequentialUpToHelper_init_unchained(uint256 sequentialUpTo_) internal onlyInitializingERC721A { + _initializeSequentialUpTo(sequentialUpTo_); + } + + function sequentialUpTo() public view returns (uint256 result) { + assembly { + result := sload(SEQUENTIAL_UP_TO_STORAGE_SLOT) + } + } + + function _initializeSequentialUpTo(uint256 value) private { + // We use assembly to directly set the `sequentialUpTo` in storage so that + // inheriting this class won't affect the layout of other storage slots. + assembly { + sstore(SEQUENTIAL_UP_TO_STORAGE_SLOT, value) + } + } +} diff --git a/contracts/mocks/WithInit.sol b/contracts/mocks/WithInit.sol index 9a3389a..b83f7da 100644 --- a/contracts/mocks/WithInit.sol +++ b/contracts/mocks/WithInit.sol @@ -68,15 +68,18 @@ contract StartTokenIdHelperUpgradeableWithInit is StartTokenIdHelperUpgradeable __StartTokenIdHelper_init(startTokenId_); } } -import './ERC721AQueryableStartTokenIdMockUpgradeable.sol'; +import './ERC721ASpotMockUpgradeable.sol'; -contract ERC721AQueryableStartTokenIdMockUpgradeableWithInit is ERC721AQueryableStartTokenIdMockUpgradeable { +contract ERC721ASpotMockUpgradeableWithInit is ERC721ASpotMockUpgradeable { constructor( string memory name_, string memory symbol_, - uint256 startTokenId_ + uint256 startTokenId_, + uint256 sequentialUpTo_, + uint256 quantity, + bool mintInConstructor ) payable initializerERC721A { - __ERC721AQueryableStartTokenIdMock_init(name_, symbol_, startTokenId_); + __ERC721ASpotMock_init(name_, symbol_, startTokenId_, sequentialUpTo_, quantity, mintInConstructor); } } import './ERC721AQueryableMockUpgradeable.sol'; @@ -86,6 +89,24 @@ contract ERC721AQueryableMockUpgradeableWithInit is ERC721AQueryableMockUpgradea __ERC721AQueryableMock_init(name_, symbol_); } } +import './SequentialUpToHelperUpgradeable.sol'; + +contract SequentialUpToHelperUpgradeableWithInit is SequentialUpToHelperUpgradeable { + constructor(uint256 sequentialUpTo_) payable initializerERC721A { + __SequentialUpToHelper_init(sequentialUpTo_); + } +} +import './ERC721AQueryableStartTokenIdMockUpgradeable.sol'; + +contract ERC721AQueryableStartTokenIdMockUpgradeableWithInit is ERC721AQueryableStartTokenIdMockUpgradeable { + constructor( + string memory name_, + string memory symbol_, + uint256 startTokenId_ + ) payable initializerERC721A { + __ERC721AQueryableStartTokenIdMock_init(name_, symbol_, startTokenId_); + } +} import './ERC721ABurnableMockUpgradeable.sol'; contract ERC721ABurnableMockUpgradeableWithInit is ERC721ABurnableMockUpgradeable { diff --git a/test/extensions/ERC721ASpot.test.js b/test/extensions/ERC721ASpot.test.js new file mode 100644 index 0000000..f9be7ca --- /dev/null +++ b/test/extensions/ERC721ASpot.test.js @@ -0,0 +1,319 @@ +const { deployContract } = require('../helpers.js'); +const { expect } = require('chai'); +const { BigNumber } = require('ethers'); +const { constants } = require('@openzeppelin/test-helpers'); +const { ZERO_ADDRESS } = constants; + +describe('ERC721ASpot', function () { + + context('constructor', function () { + const testConstructor = async (args, expectedError) => { + const deployment = deployContract('ERC721ASpotMock', args); + if (expectedError) await expect(deployment).to.be.revertedWith(expectedError); + else await deployment; + }; + + it('reverts if _sequentialUpTo is not greater than _startTokenId', async function () { + const t = async (startTokenId, sequentialUpTo, expectSuccess) => { + await testConstructor( + ['Azuki', 'AZUKI', startTokenId, sequentialUpTo, 0, false], + expectSuccess ? false : 'SequentialUpToTooSmall' + ); + }; + await t(0, 0, true); + await t(1, 0, false); + await t(0, 1, true); + await t(100, 99, false); + await t(100, 100, true); + await t(100, 101, true); + await t(100, 999, true); + }); + + it('reverts if ERC2309 mint exceeds limit', async function () { + const t = async (startTokenId, sequentialUpTo, quantity, expectSuccess) => { + await testConstructor( + ['Azuki', 'AZUKI', startTokenId, sequentialUpTo, quantity, true], + expectSuccess ? false : 'SequentialMintExceedsLimit' + ); + }; + await t(0, 1, 1, true); + await t(0, 1, 2, true); + await t(0, 1, 3, false); + await t(100, 101, 1, true); + await t(100, 101, 2, true); + await t(100, 101, 3, false); + await t(100, 109, 2, true); + await t(100, 109, 9, true); + await t(100, 109, 10, true); + await t(100, 109, 11, false); + }); + }); + + context('mint sequential and spot', function () { + beforeEach(async function () { + const [owner, addr1] = await ethers.getSigners(); + this.owner = owner; + this.addr1 = addr1; + this.startTokenId = BigNumber.from(10); + this.sequentialUpTo = BigNumber.from(19); + const args = ['Azuki', 'AZUKI', this.startTokenId, this.sequentialUpTo, 0, false]; + this.erc721aSpot = await deployContract('ERC721ASpotMock', args); + }); + + it('_mintSpot emits a Transfer event', async function () { + await expect(this.erc721aSpot.safeMintSpot(this.addr1.address, 20)) + .to.emit(this.erc721aSpot, 'Transfer') + .withArgs(ZERO_ADDRESS, this.addr1.address, 20); + }); + + it('increases _totalSpotMinted, totalSupply', async function () { + await this.erc721aSpot.safeMint(this.addr1.address, 5); + expect(await this.erc721aSpot.totalSpotMinted()).to.eq(0); + expect(await this.erc721aSpot.totalSupply()).to.eq(5); + + await this.erc721aSpot.safeMintSpot(this.addr1.address, 20); + expect(await this.erc721aSpot.totalSpotMinted()).to.eq(1); + expect(await this.erc721aSpot.totalSupply()).to.eq(6); + + await this.erc721aSpot.safeMintSpot(this.addr1.address, 30); + expect(await this.erc721aSpot.totalSpotMinted()).to.eq(2); + expect(await this.erc721aSpot.totalSupply()).to.eq(7); + }); + + it('tokensOfOwnerIn', async function () { + expect(await this.erc721aSpot.tokensOfOwnerIn(this.addr1.address, 0, 4294967295)).to.eql([]); + + await this.erc721aSpot.safeMint(this.addr1.address, 5); + expect(await this.erc721aSpot.tokensOfOwnerIn(this.addr1.address, 0, 4294967295)) + .to.eql([10, 11, 12, 13, 14].map(BigNumber.from)); + + await this.erc721aSpot.safeMintSpot(this.addr1.address, 21); + expect(await this.erc721aSpot.tokensOfOwnerIn(this.addr1.address, 0, 4294967295)) + .to.eql([10, 11, 12, 13, 14, 21].map(BigNumber.from)); + + await this.erc721aSpot.safeMintSpot(this.addr1.address, 31); + expect(await this.erc721aSpot.tokensOfOwnerIn(this.addr1.address, 0, 4294967295)) + .to.eql([10, 11, 12, 13, 14, 21, 31].map(BigNumber.from)); + + await this.erc721aSpot.safeMintSpot(this.addr1.address, 22); + expect(await this.erc721aSpot.tokensOfOwnerIn(this.addr1.address, 0, 4294967295)) + .to.eql([10, 11, 12, 13, 14, 21, 22, 31].map(BigNumber.from)); + + await this.erc721aSpot.safeMint(this.addr1.address, 5); + expect(await this.erc721aSpot.tokensOfOwnerIn(this.addr1.address, 0, 4294967295)) + .to.eql([10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 22, 31].map(BigNumber.from)); + + await this.erc721aSpot.safeMintSpot(this.addr1.address, 20); + expect(await this.erc721aSpot.tokensOfOwnerIn(this.addr1.address, 0, 4294967295)) + .to.eql([10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 31].map(BigNumber.from)); + + expect(await this.erc721aSpot.tokensOfOwnerIn(this.addr1.address, 0, 32)) + .to.eql([10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 31].map(BigNumber.from)); + + expect(await this.erc721aSpot.tokensOfOwnerIn(this.addr1.address, 0, 31)) + .to.eql([10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22].map(BigNumber.from)); + }); + + it('explicitOwnershipOf', async function () { + let explicitOwnership = await this.erc721aSpot.explicitOwnershipOf(10); + expect(explicitOwnership.addr).to.eq(ZERO_ADDRESS); + expect(explicitOwnership.burned).to.eq(false); + + await this.erc721aSpot.safeMint(this.addr1.address, 1); + explicitOwnership = await this.erc721aSpot.explicitOwnershipOf(10); + expect(explicitOwnership.addr).to.eq(this.addr1.address); + expect(explicitOwnership.burned).to.eq(false); + + explicitOwnership = await this.erc721aSpot.explicitOwnershipOf(11); + expect(explicitOwnership.addr).to.eq(ZERO_ADDRESS); + expect(explicitOwnership.burned).to.eq(false); + + explicitOwnership = await this.erc721aSpot.explicitOwnershipOf(20); + expect(explicitOwnership.addr).to.eq(ZERO_ADDRESS); + expect(explicitOwnership.burned).to.eq(false); + + await this.erc721aSpot.safeMintSpot(this.addr1.address, 20); + explicitOwnership = await this.erc721aSpot.explicitOwnershipOf(20); + expect(explicitOwnership.addr).to.eq(this.addr1.address); + expect(explicitOwnership.burned).to.eq(false); + }); + + it('tokensOfOwner reverts', async function () { + await expect(this.erc721aSpot.tokensOfOwner(this.addr1.address)).to.be.revertedWith( + 'NotCompatibleWithSpotMints' + ); + }); + + it('spot minting to an existing token reverts', async function () { + await this.erc721aSpot.safeMintSpot(this.addr1.address, 20); + await expect(this.erc721aSpot.safeMintSpot(this.addr1.address, 20)).to.be.revertedWith( + 'TokenAlreadyExists' + ); + }); + + it('reverts if sequential mint exceeds limit', async function () { + await expect(this.erc721aSpot.safeMint(this.addr1.address, 11)).to.be.revertedWith( + 'SequentialMintExceedsLimit' + ); + await this.erc721aSpot.safeMint(this.addr1.address, 10); + }); + + it('reverts if _mintSpot tokenId is too small', async function () { + await expect(this.erc721aSpot.safeMintSpot(this.addr1.address, 19)).to.be.revertedWith( + 'SpotMintTokenIdTooSmall' + ); + }); + + context('with transfers', function () { + it('reverts if token is not minted', async function () { + await this.erc721aSpot.safeMint(this.addr1.address, 10); + await expect(this.erc721aSpot + .connect(this.addr1) + .transferFrom(this.addr1.address, this.owner.address, 21)).to.be.revertedWith( + 'OwnerQueryForNonexistentToken' + ); + await this.erc721aSpot.safeMintSpot(this.addr1.address, 21); + await this.erc721aSpot + .connect(this.addr1) + .transferFrom(this.addr1.address, this.owner.address, 21); + }); + + it('edge case 1', async function () { + await this.erc721aSpot.safeMintSpot(this.addr1.address, 20); + await this.erc721aSpot.safeMint(this.addr1.address, 10); + await this.erc721aSpot.connect(this.addr1).transferFrom(this.addr1.address, this.owner.address, 20); + expect(await this.erc721aSpot.ownerOf(20)).to.eq(this.owner.address); + expect(await this.erc721aSpot.ownerOf(19)).to.eq(this.addr1.address); + expect(await this.erc721aSpot.ownerOf(18)).to.eq(this.addr1.address); + await this.erc721aSpot.connect(this.addr1).transferFrom(this.addr1.address, this.owner.address, 19); + expect(await this.erc721aSpot.ownerOf(20)).to.eq(this.owner.address); + expect(await this.erc721aSpot.ownerOf(19)).to.eq(this.owner.address); + expect(await this.erc721aSpot.ownerOf(18)).to.eq(this.addr1.address); + }); + + it('edge case 2', async function () { + await this.erc721aSpot.safeMintSpot(this.addr1.address, 20); + await this.erc721aSpot.safeMint(this.addr1.address, 10); + await this.erc721aSpot.connect(this.addr1).transferFrom(this.addr1.address, this.owner.address, 19); + expect(await this.erc721aSpot.ownerOf(20)).to.eq(this.addr1.address); + expect(await this.erc721aSpot.ownerOf(19)).to.eq(this.owner.address); + expect(await this.erc721aSpot.ownerOf(18)).to.eq(this.addr1.address); + await this.erc721aSpot.connect(this.addr1).transferFrom(this.addr1.address, this.owner.address, 20); + expect(await this.erc721aSpot.ownerOf(20)).to.eq(this.owner.address); + expect(await this.erc721aSpot.ownerOf(19)).to.eq(this.owner.address); + expect(await this.erc721aSpot.ownerOf(18)).to.eq(this.addr1.address); + }); + }); + + context('with burns', function () { + beforeEach(async function () { + await this.erc721aSpot.safeMint(this.addr1.address, 5); + await this.erc721aSpot.safeMintSpot(this.addr1.address, 20); + await this.erc721aSpot.safeMintSpot(this.addr1.address, 30); + }); + + it('sets ownership correctly', async function () { + const t = async (tokenIds) => { + for (let i = 0; i < 35; ++i) { + const tx = this.erc721aSpot.getOwnershipOf(i); + if (tokenIds.includes(i)) await tx; + else await expect(tx).to.be.revertedWith('OwnerQueryForNonexistentToken'); + } + }; + await t([10, 11, 12, 13, 14, 20, 30]); + await this.erc721aSpot.connect(this.addr1).burn(20); + await t([10, 11, 12, 13, 14, 30]); + }); + + it('reduces balanceOf, totalSupply', async function () { + expect(await this.erc721aSpot.balanceOf(this.addr1.address)).to.eq(7); + await this.erc721aSpot.connect(this.addr1).burn(10); + expect(await this.erc721aSpot.balanceOf(this.addr1.address)).to.eq(6); + expect(await this.erc721aSpot.totalSupply()).to.eq(6); + + await this.erc721aSpot.connect(this.addr1).burn(20); + expect(await this.erc721aSpot.balanceOf(this.addr1.address)).to.eq(5); + expect(await this.erc721aSpot.totalSupply()).to.eq(5); + + await this.erc721aSpot.connect(this.addr1).burn(30); + expect(await this.erc721aSpot.balanceOf(this.addr1.address)).to.eq(4); + expect(await this.erc721aSpot.totalSupply()).to.eq(4); + + await this.erc721aSpot.connect(this.addr1).burn(11); + await this.erc721aSpot.connect(this.addr1).burn(12); + await this.erc721aSpot.connect(this.addr1).burn(13); + await this.erc721aSpot.connect(this.addr1).burn(14); + expect(await this.erc721aSpot.balanceOf(this.addr1.address)).to.eq(0); + expect(await this.erc721aSpot.totalSupply()).to.eq(0); + }); + + it('does not reduce totalMinted', async function () { + expect(await this.erc721aSpot.balanceOf(this.addr1.address)).to.eq(7); + await this.erc721aSpot.connect(this.addr1).burn(10); + expect(await this.erc721aSpot.totalMinted()).to.eq(7); + + await this.erc721aSpot.connect(this.addr1).burn(20); + expect(await this.erc721aSpot.totalMinted()).to.eq(7); + + await this.erc721aSpot.connect(this.addr1).burn(30); + expect(await this.erc721aSpot.totalMinted()).to.eq(7); + }); + + it('increases _numberBurned, totalBurned', async function () { + expect(await this.erc721aSpot.balanceOf(this.addr1.address)).to.eq(7); + await this.erc721aSpot.connect(this.addr1).burn(10); + expect(await this.erc721aSpot.numberBurned(this.addr1.address)).to.eq(1); + expect(await this.erc721aSpot.totalBurned()).to.eq(1); + + await this.erc721aSpot.connect(this.addr1).burn(20); + expect(await this.erc721aSpot.numberBurned(this.addr1.address)).to.eq(2); + expect(await this.erc721aSpot.totalBurned()).to.eq(2); + + await this.erc721aSpot.connect(this.addr1).burn(30); + expect(await this.erc721aSpot.numberBurned(this.addr1.address)).to.eq(3); + expect(await this.erc721aSpot.totalBurned()).to.eq(3); + + await this.erc721aSpot.connect(this.addr1).burn(11); + await this.erc721aSpot.connect(this.addr1).burn(12); + await this.erc721aSpot.connect(this.addr1).burn(13); + await this.erc721aSpot.connect(this.addr1).burn(14); + expect(await this.erc721aSpot.numberBurned(this.addr1.address)).to.eq(7); + expect(await this.erc721aSpot.totalBurned()).to.eq(7); + }); + + it('affects _exists', async function () { + expect(await this.erc721aSpot.exists(0)).to.eq(false); + expect(await this.erc721aSpot.exists(9)).to.eq(false); + expect(await this.erc721aSpot.exists(10)).to.eq(true); + + expect(await this.erc721aSpot.exists(20)).to.eq(true); + + await this.erc721aSpot.connect(this.addr1).burn(20); + expect(await this.erc721aSpot.exists(20)).to.eq(false); + + this.erc721aSpot.safeMintSpot(this.owner.address, 20); + expect(await this.erc721aSpot.exists(20)).to.eq(true); + }); + + it('forwards extraData after burn and re-mint', async function () { + await this.erc721aSpot.setExtraDataAt(20, 123); + let explicitOwnership = await this.erc721aSpot.explicitOwnershipOf(20); + expect(explicitOwnership.addr).to.eq(this.addr1.address); + expect(explicitOwnership.burned).to.eq(false); + expect(explicitOwnership.extraData).to.eq(123); + + await this.erc721aSpot.connect(this.addr1).burn(20); + explicitOwnership = await this.erc721aSpot.explicitOwnershipOf(20); + expect(explicitOwnership.addr).to.eq(this.addr1.address); + expect(explicitOwnership.burned).to.eq(true); + expect(explicitOwnership.extraData).to.eq(123); + + this.erc721aSpot.safeMintSpot(this.owner.address, 20); + explicitOwnership = await this.erc721aSpot.explicitOwnershipOf(20); + expect(explicitOwnership.addr).to.eq(this.owner.address); + expect(explicitOwnership.burned).to.eq(false); + expect(explicitOwnership.extraData).to.eq(123); + }); + }); + }); +});