diff --git a/packages/avatar/contracts/mocks/MockERC20.sol b/packages/avatar/contracts/mocks/MockERC20.sol index 1384ec795c..f65996f188 100644 --- a/packages/avatar/contracts/mocks/MockERC20.sol +++ b/packages/avatar/contracts/mocks/MockERC20.sol @@ -10,6 +10,7 @@ contract MockERC20 is ERC20, Ownable { address target; address wallet; uint256 amount; + uint256 waveIndex; uint256 signatureId; bytes signature; } @@ -42,10 +43,34 @@ contract MockERC20 is ERC20, Ownable { uint256 _signatureId, bytes calldata _signature ) external { - mintArgs = MintArgs({target : _target, wallet : _wallet, amount : _amount, signatureId : _signatureId, signature : _signature}); + mintArgs = MintArgs({target : _target, wallet : _wallet, amount : _amount, waveIndex : 0, signatureId : _signatureId, signature : _signature}); MintInterface(_target).mint(_wallet, _amount, _signatureId, _signature); } + /// @dev instead of using approve and call we use this method directly for testing. + function waveMint( + MintInterface target, + address _wallet, + uint256 _amount, + uint256 _waveIndex, + uint256 _signatureId, + bytes calldata _signature + ) external { + target.waveMint(_wallet, _amount, _waveIndex, _signatureId, _signature); + } + + function waveMintReenter( + address _target, + address _wallet, + uint256 _amount, + uint256 _waveIndex, + uint256 _signatureId, + bytes calldata _signature + ) external { + mintArgs = MintArgs({target : _target, wallet : _wallet, amount : _amount, waveIndex : _waveIndex, signatureId : _signatureId, signature : _signature}); + MintInterface(_target).waveMint(_wallet, _amount, _waveIndex, _signatureId, _signature); + } + /// @notice Approve `target` to spend `amount` and call it with data. /// @param target The address to be given rights to transfer and destination of the call. /// @param amount The number of tokens allowed. @@ -115,4 +140,12 @@ interface MintInterface { uint256 _signatureId, bytes calldata _signature ) external; + + function waveMint( + address wallet, + uint256 amount, + uint256 waveIndex, + uint256 signatureId, + bytes calldata signature + ) external; } \ No newline at end of file diff --git a/packages/avatar/contracts/mocks/NFTCollectionMock.sol b/packages/avatar/contracts/mocks/NFTCollectionMock.sol index 46b863012a..7c7cff1d6d 100644 --- a/packages/avatar/contracts/mocks/NFTCollectionMock.sol +++ b/packages/avatar/contracts/mocks/NFTCollectionMock.sol @@ -12,6 +12,7 @@ contract NFTCollectionMock is NFTCollection { bytes32 erc2771HandlerUpgradable; bytes32 updatableOperatorFiltererUpgradeable; bytes32 nftCollection; + bytes32 nftCollectionSignature; } /// @custom:oz-upgrades-unsafe-allow constructor @@ -49,6 +50,7 @@ contract NFTCollectionMock is NFTCollection { ret.erc2771HandlerUpgradable = ERC2771_HANDLER_UPGRADABLE_STORAGE_LOCATION; ret.updatableOperatorFiltererUpgradeable = UPDATABLE_OPERATOR_FILTERER_UPGRADABLE_STORAGE_LOCATION; ret.nftCollection = NFT_COLLECTION_STORAGE_LOCATION; + ret.nftCollectionSignature = NFT_COLLECTION_SIGNATURE_STORAGE_LOCATION; } /** diff --git a/packages/avatar/contracts/nft-collection/ERC2771HandlerUpgradeable.sol b/packages/avatar/contracts/nft-collection/ERC2771HandlerUpgradeable.sol index 0a00ba0631..2547e71aec 100644 --- a/packages/avatar/contracts/nft-collection/ERC2771HandlerUpgradeable.sol +++ b/packages/avatar/contracts/nft-collection/ERC2771HandlerUpgradeable.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; +import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable-5.0.2/utils/ContextUpgradeable.sol"; + /** * @title ERC2771HandlerUpgradeable * @author The Sandbox @@ -9,7 +11,7 @@ pragma solidity 0.8.26; * @dev based on: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.6.0/contracts/metatx/ERC2771Context.sol * with an initializer for proxies and a mutable forwarder */ -abstract contract ERC2771HandlerUpgradeable { +abstract contract ERC2771HandlerUpgradeable is ContextUpgradeable { struct ERC2771HandlerUpgradeableStorage { address trustedForwarder; } @@ -69,7 +71,7 @@ abstract contract ERC2771HandlerUpgradeable { * @dev Defaults to the original `msg.sender` whenever a call is not performed by the trusted forwarder * or the calldata length is less than 20 bytes (an address length). */ - function _msgSender() internal view virtual returns (address) { + function _msgSender() internal view override virtual returns (address) { uint256 calldataLength = msg.data.length; uint256 contextSuffixLength = _contextSuffixLength(); if (_isTrustedForwarder(msg.sender) && calldataLength >= contextSuffixLength) { @@ -85,7 +87,7 @@ abstract contract ERC2771HandlerUpgradeable { * @dev Defaults to the original `msg.data` whenever a call is not performed by the trusted forwarder * or the calldata length is less than 20 bytes (an address length). */ - function _msgData() internal view virtual returns (bytes calldata) { + function _msgData() internal view override virtual returns (bytes calldata) { uint256 calldataLength = msg.data.length; uint256 contextSuffixLength = _contextSuffixLength(); if (_isTrustedForwarder(msg.sender) && calldataLength >= contextSuffixLength) { @@ -108,7 +110,7 @@ abstract contract ERC2771HandlerUpgradeable { /** * @notice ERC-2771 specifies the context as being a single address (20 bytes). */ - function _contextSuffixLength() internal view virtual returns (uint256) { + function _contextSuffixLength() internal view override virtual returns (uint256) { return 20; } } diff --git a/packages/avatar/contracts/nft-collection/INFTCollection.sol b/packages/avatar/contracts/nft-collection/INFTCollection.sol index d59265fec4..d117aa2e8d 100644 --- a/packages/avatar/contracts/nft-collection/INFTCollection.sol +++ b/packages/avatar/contracts/nft-collection/INFTCollection.sol @@ -78,15 +78,6 @@ interface INFTCollection { */ event BaseURISet(address indexed operator, string oldBaseURI, string newBaseURI); - /** - * @notice Event emitted when the signer address was set or changed - * @dev emitted when setSignAddress is called - * @param operator the sender of the transaction - * @param oldSignAddress old signer address that is allowed to create mint signatures - * @param newSignAddress new signer address that is allowed to create mint signatures - */ - event SignAddressSet(address indexed operator, address indexed oldSignAddress, address indexed newSignAddress); - /** * @notice Event emitted when the max supply is set or changed * @dev emitted when setSignAddress is called @@ -166,12 +157,6 @@ interface INFTCollection { */ error InvalidTreasury(address mintTreasury); - /** - * @notice The operation failed because the signAddress is wrong - * @param signAddress signer address that is allowed to create mint signatures - */ - error InvalidSignAddress(address signAddress); - /** * @notice The operation failed because the allowedToExecuteMint is not a contract or wrong * @param allowedToExecuteMint token address that is used for payments and that is allowed to execute mint @@ -190,12 +175,6 @@ interface INFTCollection { */ error InvalidBatchData(); - /** - * @notice The operation failed because signature is invalid or it was already used - * @param signatureId the ID of the provided signature - */ - error InvalidSignature(uint256 signatureId); - /** * @notice The operation failed because the wave arguments are wrong * @param waveMaxTokensOverall the allowed number of tokens to be minted in this wave (cumulative by all minting wallets) diff --git a/packages/avatar/contracts/nft-collection/NFTCollection.sol b/packages/avatar/contracts/nft-collection/NFTCollection.sol index e46cd2f05b..922ddc689c 100644 --- a/packages/avatar/contracts/nft-collection/NFTCollection.sol +++ b/packages/avatar/contracts/nft-collection/NFTCollection.sol @@ -8,7 +8,6 @@ import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable-5.0.2/util import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable-5.0.2/utils/PausableUpgradeable.sol"; import {ERC2981Upgradeable} from "@openzeppelin/contracts-upgradeable-5.0.2/token/common/ERC2981Upgradeable.sol"; import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable-5.0.2/token/ERC721/ERC721Upgradeable.sol"; -import {ECDSA} from "@openzeppelin/contracts-5.0.2/utils/cryptography/ECDSA.sol"; import {IERC20} from "@openzeppelin/contracts-5.0.2/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts-5.0.2/token/ERC20/extensions/IERC20Metadata.sol"; import {SafeERC20} from "@openzeppelin/contracts-5.0.2/token/ERC20/utils/SafeERC20.sol"; @@ -17,6 +16,7 @@ import {IERC4906} from "../common/IERC4906.sol"; import {UpdatableOperatorFiltererUpgradeable} from "./UpdatableOperatorFiltererUpgradeable.sol"; import {ERC2771HandlerUpgradeable} from "./ERC2771HandlerUpgradeable.sol"; import {ERC721BurnMemoryUpgradeable} from "./ERC721BurnMemoryUpgradeable.sol"; +import {NFTCollectionSignature} from "./NFTCollectionSignature.sol"; import {INFTCollection} from "./INFTCollection.sol"; /** @@ -46,6 +46,7 @@ ERC2981Upgradeable, ERC2771HandlerUpgradeable, UpdatableOperatorFiltererUpgradeable, PausableUpgradeable, +NFTCollectionSignature, IERC4906, INFTCollection { @@ -102,23 +103,11 @@ INFTCollection */ IERC20 allowedToExecuteMint; - /** - * @notice all signatures must come from this specific address, otherwise they are invalid - */ - address signAddress; - /** * @notice stores the personalization mask for a tokenId */ mapping(uint256 => uint256) personalizationTraits; - /** - * @notice map used to mark if a specific signatureId was used - * values are 0 (default, unused) and 1 (used) - * Used to avoid a signature reuse - */ - mapping(uint256 => uint256) signatureIds; - /** * @notice total amount of tokens minted till now */ @@ -135,6 +124,7 @@ INFTCollection $.slot := NFT_COLLECTION_STORAGE_LOCATION } } + /** * @notice mitigate a possible Implementation contract takeover, as indicate by * https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#initializing_the_implementation_contract @@ -236,7 +226,7 @@ INFTCollection } /** - * @notice function to setup wave parameters. A wave is defined as a combination of allowed number tokens to be + * @notice function to setup a new wave. A wave is defined as a combination of allowed number tokens to be * minted in total, per wallet and minting price * @custom:event {WaveSetup} * @param _waveMaxTokensOverall the allowed number of tokens to be minted in this wave (cumulative by all minting wallets) @@ -249,17 +239,31 @@ INFTCollection uint256 _waveMaxTokensPerWallet, uint256 _waveSingleTokenPrice ) external onlyOwner { - _setupWave(_waveMaxTokensOverall, _waveMaxTokensPerWallet, _waveSingleTokenPrice); + NFTCollectionStorage storage $ = _getNFTCollectionStorage(); + if (_waveMaxTokensOverall > $.maxSupply || + _waveMaxTokensOverall == 0 || + _waveMaxTokensPerWallet == 0 || + _waveMaxTokensPerWallet > _waveMaxTokensOverall + ) { + revert InvalidWaveData(_waveMaxTokensOverall, _waveMaxTokensPerWallet); + } + uint256 waveIndex = $.waveData.length; + emit WaveSetup(_msgSender(), _waveMaxTokensOverall, _waveMaxTokensPerWallet, _waveSingleTokenPrice, waveIndex); + $.waveData.push(); + $.waveData[waveIndex].waveMaxTokensOverall = _waveMaxTokensOverall; + $.waveData[waveIndex].waveMaxTokensPerWallet = _waveMaxTokensPerWallet; + $.waveData[waveIndex].waveSingleTokenPrice = _waveSingleTokenPrice; } /** - * @notice token minting function. Price is set by wave and is paid in tokens denoted + * @notice token minting function on the last wave. Price is set by wave and is paid in tokens denoted * by the allowedToExecuteMint contract * @custom:event {Transfer} * @param wallet minting wallet * @param amount number of token to mint * @param signatureId signing signature ID * @param signature signing signature value + * @dev this method is backward compatible with the previous contract, so, it uses last configured wave */ function mint( address wallet, @@ -268,61 +272,93 @@ INFTCollection bytes calldata signature ) external whenNotPaused nonReentrant { NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - uint256 _indexWave = $.waveData.length; - if (_indexWave == 0) { + uint256 waveIndex = $.waveData.length; + if (waveIndex == 0) { revert ContractNotConfigured(); } - WaveData storage wd = $.waveData[_indexWave - 1]; if (_msgSender() != address($.allowedToExecuteMint)) { revert ERC721InvalidSender(_msgSender()); } - _checkAndSetSignature(wallet, signatureId, signature); - _checkMintAllowed(wallet, amount); - uint256 _price = wd.waveSingleTokenPrice * amount; - if (_price > 0) { - SafeERC20.safeTransferFrom($.allowedToExecuteMint, wallet, $.mintTreasury, _price); + _checkAndSetMintSignature(wallet, signatureId, signature); + WaveData storage waveData = $.waveData[waveIndex - 1]; + _doMint(waveData, wallet, amount); + } + + /** + * @notice token minting function on a certain wave. Price is set by wave and is paid in tokens denoted + * by the allowedToExecuteMint contract + * @custom:event {Transfer} + * @param wallet minting wallet + * @param amount number of token to mint + * @param waveIndex the index of the wave used to mint + * @param signatureId signing signature ID + * @param signature signing signature value + */ + function waveMint( + address wallet, + uint256 amount, + uint256 waveIndex, + uint256 signatureId, + bytes calldata signature + ) external whenNotPaused nonReentrant { + NFTCollectionStorage storage $ = _getNFTCollectionStorage(); + if ($.waveData.length == 0) { + revert ContractNotConfigured(); } - uint256 _totalSupply = $.totalSupply; - for (uint256 i; i < amount; i++) { - // @dev _safeMint already checks the destination _wallet - // @dev start with tokenId = 1 - _safeMint(wallet, _totalSupply + i + 1); + if (_msgSender() != address($.allowedToExecuteMint)) { + revert ERC721InvalidSender(_msgSender()); } - wd.waveOwnerToClaimedCounts[wallet] += amount; - wd.waveTotalMinted += amount; - $.totalSupply += amount; + _checkAndSetWaveMintSignature(wallet, amount, waveIndex, signatureId, signature); + WaveData storage waveData = _getWaveData(waveIndex); + _doMint(waveData, wallet, amount); + } + + /** + * @notice function to setup wave parameters. A wave is defined as a combination of allowed number tokens to be + * minted in total, per wallet and minting price + * @custom:event {WaveSetup} + * @param waveIndex the index of the wave to be canceled + */ + function cancelWave(uint256 waveIndex) external onlyOwner { + NFTCollectionStorage storage $ = _getNFTCollectionStorage(); + /// @dev don't use _getWaveData, we don't want to cancel the last wave by mistake + if (waveIndex >= $.waveData.length) { + revert ContractNotConfigured(); + } + $.waveData[waveIndex].waveMaxTokensOverall = 0; } /** * @notice batch minting function, used by owner to airdrop directly to users. * @dev this methods takes a list of destination wallets and can only be used by the owner of the contract * @custom:event {Transfer} + * @param waveIndex the index of the wave used to mint * @param wallets list of destination wallets and amounts */ - function batchMint(BatchMintingData[] calldata wallets) external whenNotPaused onlyOwner { + function batchMint(uint256 waveIndex, BatchMintingData[] calldata wallets) external whenNotPaused onlyOwner { NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - uint256 _indexWave = $.waveData.length; - if (_indexWave == 0) { - revert ContractNotConfigured(); - } uint256 len = wallets.length; if (len == 0) { revert InvalidBatchData(); } - - WaveData storage wd = $.waveData[_indexWave - 1]; + if ($.waveData.length == 0) { + revert ContractNotConfigured(); + } + WaveData storage waveData = _getWaveData(waveIndex); for (uint256 i; i < len; i++) { uint256 _totalSupply = $.totalSupply; address wallet = wallets[i].wallet; uint256 amount = wallets[i].amount; - _checkMintAllowed(wallet, amount); + if (!_isMintAllowed($, waveData, wallet, amount)) { + revert CannotMint(wallet, amount); + } for (uint256 j; j < amount; j++) { // @dev _mint already checks the destination wallet // @dev start with tokenId = 1 _mint(wallet, _totalSupply + j + 1); } - wd.waveOwnerToClaimedCounts[wallet] += amount; - wd.waveTotalMinted += amount; + waveData.waveOwnerToClaimedCounts[wallet] += amount; + waveData.waveTotalMinted += amount; $.totalSupply += amount; } } @@ -347,9 +383,7 @@ INFTCollection if (owner != sender) { revert ERC721IncorrectOwner(sender, tokenId, owner); } - - _checkAndSetSignature(sender, signatureId, signature); - + _checkAndSetRevealSignature(sender, signatureId, signature); emit MetadataUpdate(tokenId); } @@ -358,16 +392,16 @@ INFTCollection * @dev after checks, it is reduced to personalizationTraits[_tokenId] = _personalizationMask * @custom:event {Personalized} * @custom:event {MetadataUpdate} - * @param signatureId the ID of the provided signature - * @param signature signing signature * @param tokenId what token to personalize * @param personalizationMask a mask where each bit has a custom meaning in-game + * @param signatureId the ID of the provided signature + * @param signature signing signature */ function personalize( - uint256 signatureId, - bytes calldata signature, uint256 tokenId, - uint256 personalizationMask + uint256 personalizationMask, + uint256 signatureId, + bytes calldata signature ) external whenNotPaused { address sender = _msgSender(); address owner = ownerOf(tokenId); @@ -679,12 +713,16 @@ INFTCollection * @notice check if the indicated wallet can mint the indicated amount * @param wallet wallet to be checked if it can mint * @param amount amount to be checked if can be minted + * @param waveIndex the index of the wave used to mint * @return if can mint or not */ - function isMintAllowed(address wallet, uint256 amount) external view returns (bool) { + function isMintAllowed(uint256 waveIndex, address wallet, uint256 amount) external view returns (bool) { NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - uint256 waveClaimedCounts = $.waveData[$.waveData.length - 1].waveOwnerToClaimedCounts[wallet]; - return _isMintAllowed(waveClaimedCounts, amount); + if (waveIndex >= $.waveData.length) { + return false; + } + WaveData storage waveData = $.waveData[waveIndex]; + return _isMintAllowed($, waveData, wallet, amount); } /** @@ -730,54 +768,54 @@ INFTCollection /** * @notice return max tokens to buy per wave, cumulating all addresses - * @param waveIndex the wave for which the count is returned + * @param waveIndex the index of the wave used to mint */ function waveMaxTokensOverall(uint256 waveIndex) external view returns (uint256) { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - return $.waveData[waveIndex].waveMaxTokensOverall; + WaveData storage waveData = _getWaveData(waveIndex); + return waveData.waveMaxTokensOverall; } /** * @notice return max tokens to buy, per wallet in a given wave - * @param waveIndex the wave for which the count is returned + * @param waveIndex the index of the wave used to mint */ function waveMaxTokensPerWallet(uint256 waveIndex) external view returns (uint256) { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - return $.waveData[waveIndex].waveMaxTokensPerWallet; + WaveData storage waveData = _getWaveData(waveIndex); + return waveData.waveMaxTokensPerWallet; } /** * @notice return price of one token mint (in the token denoted by the allowedToExecuteMint contract) - * @param waveIndex the wave for which the count is returned + * @param waveIndex the index of the wave used to mint */ function waveSingleTokenPrice(uint256 waveIndex) external view returns (uint256) { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - return $.waveData[waveIndex].waveSingleTokenPrice; + WaveData storage waveData = _getWaveData(waveIndex); + return waveData.waveSingleTokenPrice; } /** * @notice return number of total minted tokens in the current running wave - * @param waveIndex the wave for which the count is returned + * @param waveIndex the index of the wave used to mint */ function waveTotalMinted(uint256 waveIndex) external view returns (uint256) { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - return $.waveData[waveIndex].waveTotalMinted; + WaveData storage waveData = _getWaveData(waveIndex); + return waveData.waveTotalMinted; } /** * @notice return mapping of [owner -> wave index -> minted count] - * @param waveIndex the wave for which the count is returned + * @param waveIndex the index of the wave used to mint * @param owner the owner for which the count is returned */ function waveOwnerToClaimedCounts(uint256 waveIndex, address owner) external view returns (uint256) { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - return $.waveData[waveIndex].waveOwnerToClaimedCounts[owner]; + WaveData storage waveData = _getWaveData(waveIndex); + return waveData.waveOwnerToClaimedCounts[owner]; } /** - * @notice return each wave has an index to help track minting/tokens per wallet + * @notice the total amount of waves configured till now */ - function indexWave() external view returns (uint256) { + function waveCount() external view returns (uint256) { NFTCollectionStorage storage $ = _getNFTCollectionStorage(); return $.waveData.length; } @@ -790,23 +828,6 @@ INFTCollection return $.allowedToExecuteMint; } - /** - * @notice return the address from which all signatures must come from this specific address, otherwise they are invalid - */ - function signAddress() external view returns (address) { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - return $.signAddress; - } - - /** - * @notice return true if the signature id was used - * @param signatureId signing signature ID - */ - function isSignatureUsed(uint256 signatureId) external view returns (bool) { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - return $.signatureIds[signatureId] > 0; - } - /** * @notice return the total amount of tokens minted till now */ @@ -829,33 +850,59 @@ INFTCollection } /** - * @notice function to setup wave parameters. A wave is defined as a combination of allowed number tokens to be - * minted in total, per wallet and minting price - * @custom:event {WaveSetup} - * @param _waveMaxTokensOverall the allowed number of tokens to be minted in this wave (cumulative by all minting wallets) - * @param _waveMaxTokensPerWallet max tokens to buy, per wallet in a given wave - * @param _waveSingleTokenPrice the price to mint a token in a given wave, in wei - * denoted by the allowedToExecuteMint contract + * @notice complete the minting called from waveMint and mint + * @custom:event {Transfer} + * @param waveData the data of the wave used to mint + * @param wallet minting wallet + * @param amount number of token to mint */ - function _setupWave( - uint256 _waveMaxTokensOverall, - uint256 _waveMaxTokensPerWallet, - uint256 _waveSingleTokenPrice - ) internal { + function _doMint(WaveData storage waveData, address wallet, uint256 amount) internal { NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - if (_waveMaxTokensOverall > $.maxSupply || - _waveMaxTokensOverall == 0 || - _waveMaxTokensPerWallet == 0 || - _waveMaxTokensPerWallet > _waveMaxTokensOverall - ) { - revert InvalidWaveData(_waveMaxTokensOverall, _waveMaxTokensPerWallet); + if (!_isMintAllowed($, waveData, wallet, amount)) { + revert CannotMint(wallet, amount); } - uint256 _indexWave = $.waveData.length; - emit WaveSetup(_msgSender(), _waveMaxTokensOverall, _waveMaxTokensPerWallet, _waveSingleTokenPrice, _indexWave); - $.waveData.push(); - $.waveData[_indexWave].waveMaxTokensOverall = _waveMaxTokensOverall; - $.waveData[_indexWave].waveMaxTokensPerWallet = _waveMaxTokensPerWallet; - $.waveData[_indexWave].waveSingleTokenPrice = _waveSingleTokenPrice; + uint256 _price = waveData.waveSingleTokenPrice * amount; + if (_price > 0) { + SafeERC20.safeTransferFrom($.allowedToExecuteMint, wallet, $.mintTreasury, _price); + } + uint256 _totalSupply = $.totalSupply; + for (uint256 i; i < amount; i++) { + // @dev _safeMint already checks the destination _wallet + // @dev start with tokenId = 1 + _safeMint(wallet, _totalSupply + i + 1); + } + waveData.waveOwnerToClaimedCounts[wallet] += amount; + waveData.waveTotalMinted += amount; + $.totalSupply += amount; + } + + /** + * @notice return true if the indicated wallet can mint the indicated amount + * @param $ storage access + * @param waveData wave data used to check + * @param wallet wallet to be checked if it can mint + * @param amount amount to be checked if can be minted + */ + function _isMintAllowed(NFTCollectionStorage storage $, WaveData storage waveData, address wallet, uint256 amount) internal view returns (bool) { + return amount > 0 + && (waveData.waveTotalMinted + amount <= waveData.waveMaxTokensOverall) + && (waveData.waveOwnerToClaimedCounts[wallet] + amount <= waveData.waveMaxTokensPerWallet) + && $.totalSupply + amount <= $.maxSupply; + } + + /** + * @notice a helper function to ensure consistency when waveIndex is passed as argument to an external function + * @param waveIndex the index of the wave used to mint + * @return waveData the wave data used + * @dev we accept waveIndex gte to waveData.length so we can access the wave used by mint easily + */ + function _getWaveData(uint256 waveIndex) internal view returns (WaveData storage waveData){ + NFTCollectionStorage storage $ = _getNFTCollectionStorage(); + uint256 waveDataLen = $.waveData.length; + if (waveIndex >= waveDataLen) { + waveIndex = waveDataLen - 1; + } + return $.waveData[waveIndex]; } /** @@ -882,7 +929,7 @@ INFTCollection function _msgSender() internal view - override(ContextUpgradeable, ERC2771HandlerUpgradeable) + override(ContextUpgradeable, ERC2771HandlerUpgradeable, UpdatableOperatorFiltererUpgradeable, NFTCollectionSignature) returns (address sender) { sender = ERC2771HandlerUpgradeable._msgSender(); @@ -895,127 +942,6 @@ INFTCollection return ERC2771HandlerUpgradeable._contextSuffixLength(); } - - /** - * @notice checks that the provided signature is valid, while also taking into - * consideration the provided address and signatureId. - * @param wallet address to be used in validating the signature - * @param signatureId signing signature ID - * @param signature signing signature value - */ - function _checkAndSetSignature( - address wallet, - uint256 signatureId, - bytes calldata signature - ) internal { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - if ($.signatureIds[signatureId] > 0 || _getSignature(wallet, signatureId, address(this), block.chainid, signature) != $.signAddress) { - revert InvalidSignature(signatureId); - } - $.signatureIds[signatureId] = 1; - } - - /** - * @notice checks that the provided personalization signature is valid, while also taking into - * consideration the provided address and signatureId. - * @param wallet address to be used in validating the signature - * @param signatureId signing signature ID - * @param signature signing signature value - * @param tokenId what token to personalize - * @param personalizationMask a mask where each bit has a custom meaning in-game - */ - function _checkAndSetPersonalizationSignature( - address wallet, - uint256 tokenId, - uint256 personalizationMask, - uint256 signatureId, - bytes calldata signature - ) internal { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - if ($.signatureIds[signatureId] > 0 || - _getPersonalizationSignature( - wallet, - signatureId, - address(this), - block.chainid, - tokenId, - personalizationMask, - signature - ) != $.signAddress) { - revert InvalidSignature(signatureId); - } - $.signatureIds[signatureId] = 1; - } - - /** - * @notice validates signature - * @param _wallet wallet that was used in signature generation - * @param _signatureId id of signature - * @param _contractAddress contract address that was used in signature generation - * @param _chainId chain ID for which the signature was generated - * @param _signature signature - * @return address that validates the provided signature - */ - function _getSignature( - address _wallet, - uint256 _signatureId, - address _contractAddress, - uint256 _chainId, - bytes calldata _signature - ) internal pure returns (address) { - return - ECDSA.recover( - keccak256( - abi.encodePacked( - "\x19Ethereum Signed Message:\n32", - keccak256(abi.encode(_wallet, _signatureId, _contractAddress, _chainId)) - ) - ), - _signature - ); - } - - /** - * @notice validate personalization mask - * @param _wallet wallet that was used in signature generation - * @param _signatureId id of signature - * @param _contractAddress contract address that was used in signature generation - * @param _chainId chain ID for which the signature was generated - * @param _tokenId token ID for which the signature was generated - * @param _personalizationMask a mask where each bit has a custom meaning in-game - * @param _signature signature - * @return address that validates the provided signature - */ - function _getPersonalizationSignature( - address _wallet, - uint256 _signatureId, - address _contractAddress, - uint256 _chainId, - uint256 _tokenId, - uint256 _personalizationMask, - bytes calldata _signature - ) internal pure returns (address) { - return - ECDSA.recover( - keccak256( - abi.encodePacked( - "\x19Ethereum Signed Message:\n32", - keccak256( - abi.encode( - _wallet, - _signatureId, - _contractAddress, - _chainId, - _tokenId, - _personalizationMask - ) - ) - ) - ), - _signature - ); - } - /** * @notice actually updates the variables that store the personalization traits per token. * @dev no checks are done on input validations. Calling functions are expected to do them @@ -1071,20 +997,6 @@ INFTCollection $.mintTreasury = _treasury; } - /** - * @notice updates the sign address. - * @custom:event {SignAddressSet} - * @param _signAddress new signer address to be set - */ - function _setSignAddress(address _signAddress) internal { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - if (_signAddress == address(0)) { - revert InvalidSignAddress(_signAddress); - } - emit SignAddressSet(_msgSender(), $.signAddress, _signAddress); - $.signAddress = _signAddress; - } - /** * @notice updates which address is allowed to execute the mint function. * @dev also resets default mint price @@ -1118,35 +1030,6 @@ INFTCollection $.maxSupply = _maxSupply; } - - /** - * @notice check if the indicated wallet can mint the indicated amount - * @param _wallet wallet to be checked if it can mint - * @param _amount amount to be checked if can be minted - */ - function _checkMintAllowed(address _wallet, uint256 _amount) internal view { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - uint256 waveClaimedCounts = $.waveData[$.waveData.length - 1].waveOwnerToClaimedCounts[_wallet]; - if (!_isMintAllowed(waveClaimedCounts, _amount)) { - revert CannotMint(_wallet, _amount); - } - } - - /** - * @notice check if the indicated wallet can mint the indicated amount - * @param _amount amount to be checked if can be minted - * @param _waveClaimedCounts amount of tokens already claimed by caller in the current running wave - * @return if can mint or not - */ - function _isMintAllowed(uint256 _waveClaimedCounts, uint256 _amount) internal view returns (bool) { - NFTCollectionStorage storage $ = _getNFTCollectionStorage(); - uint256 _indexWave = $.waveData.length; - return _amount > 0 - && ($.waveData[_indexWave - 1].waveTotalMinted + _amount <= $.waveData[_indexWave - 1].waveMaxTokensOverall) - && (_waveClaimedCounts + _amount <= $.waveData[_indexWave - 1].waveMaxTokensPerWallet) - && $.totalSupply + _amount <= $.maxSupply; - } - /** * @notice taken from ERC721Upgradeable because it is declared private. * @dev Private function to invoke {IERC721Receiver-onERC721Received} on a target address. This will revert if the diff --git a/packages/avatar/contracts/nft-collection/NFTCollectionSignature.sol b/packages/avatar/contracts/nft-collection/NFTCollectionSignature.sol new file mode 100644 index 0000000000..163ec54839 --- /dev/null +++ b/packages/avatar/contracts/nft-collection/NFTCollectionSignature.sol @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.26; + +import {ECDSA} from "@openzeppelin/contracts-5.0.2/utils/cryptography/ECDSA.sol"; + +/** + * @title NFTCollectionSignature + * @author The Sandbox + * @custom:security-contact contact-blockchain@sandbox.game + * @notice Signatures accepted by the NFTCollection + * @dev We have a set of different signatures to be backward compatible with previous collections + * @dev We must be sure that all the signatures are different and cannot be reused so we added a string to reveal. + * @dev mint: ['address', 'uint256', 'address', 'uint256'] + * @dev reveal: ['address', 'uint256', 'address', 'uint256', 'string'] + * @dev personalize: ['address', 'uint256', 'address', 'uint256', 'uint256', 'uint256'] + * @dev waveMint: ['address', 'uint256', 'uint256', 'uint256', 'address', 'uint256'] + */ +abstract contract NFTCollectionSignature { + enum SignatureType { + Unused, + Mint, + Personalization, + Reveal, + WaveMint + } + + struct NFTCollectionSignatureStorage { + + /** + * @notice all signatures must come from this specific address, otherwise they are invalid + */ + address signAddress; + + /** + * @notice map used to mark if a specific signatureId was used + * values are 0 (default, unused) and 1 (used) + * Used to avoid a signature reuse + */ + mapping(uint256 => SignatureType) signatureIds; + } + + /** + * @notice Event emitted when the signer address was set or changed + * @dev emitted when setSignAddress is called + * @param operator the sender of the transaction + * @param oldSignAddress old signer address that is allowed to create mint signatures + * @param newSignAddress new signer address that is allowed to create mint signatures + */ + event SignAddressSet(address indexed operator, address indexed oldSignAddress, address indexed newSignAddress); + + /** + * @notice The operation failed because signature is invalid or it was already used + * @param signatureId the ID of the provided signature + */ + error InvalidSignature(uint256 signatureId); + + /** + * @notice The operation failed because the signAddress is wrong + * @param signAddress signer address that is allowed to create mint signatures + */ + error InvalidSignAddress(address signAddress); + + /// @custom:storage-location erc7201:thesandbox.storage.avatar.nft-collection.NFTCollectionSignature + bytes32 internal constant NFT_COLLECTION_SIGNATURE_STORAGE_LOCATION = + 0x40778db7ee4c29e622e04906f2c4ade86f805ca9734a7b64bb0f84f333357900; + + function _getNFTCollectionSignatureStorage() private pure returns (NFTCollectionSignatureStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := NFT_COLLECTION_SIGNATURE_STORAGE_LOCATION + } + } + + /** + * @notice return the address from which all signatures must come from this specific address, otherwise they are invalid + */ + function signAddress() external view returns (address) { + NFTCollectionSignatureStorage storage $ = _getNFTCollectionSignatureStorage(); + return $.signAddress; + } + + /** + * @notice return true if the signature id was used + * @param signatureId signing signature ID + */ + function getSignatureType(uint256 signatureId) external view returns (SignatureType) { + NFTCollectionSignatureStorage storage $ = _getNFTCollectionSignatureStorage(); + return $.signatureIds[signatureId]; + } + + /** + * @notice updates the sign address. + * @custom:event {SignAddressSet} + * @param _signAddress new signer address to be set + */ + function _setSignAddress(address _signAddress) internal { + NFTCollectionSignatureStorage storage $ = _getNFTCollectionSignatureStorage(); + if (_signAddress == address(0)) { + revert InvalidSignAddress(_signAddress); + } + emit SignAddressSet(_msgSender(), $.signAddress, _signAddress); + $.signAddress = _signAddress; + } + + /** + * @notice checks that the provided signature is valid, while also taking into + * consideration the provided address and signatureId. + * @param wallet address to be used in validating the signature + * @param signatureId signing signature ID + * @param signature signing signature value + */ + function _checkAndSetMintSignature( + address wallet, + uint256 signatureId, + bytes calldata signature + ) internal { + NFTCollectionSignatureStorage storage $ = _getNFTCollectionSignatureStorage(); + if ($.signatureIds[signatureId] != SignatureType.Unused + || _getMintSignature(wallet, signatureId, address(this), block.chainid, signature) != $.signAddress) { + revert InvalidSignature(signatureId); + } + $.signatureIds[signatureId] = SignatureType.Mint; + } + + /** + * @notice checks that the provided signature is valid, while also taking into + * consideration the provided address and signatureId. + * @param wallet address to be used in validating the signature + * @param amount number of token to mint + * @param waveIndex the index of the wave that is used to mint + * @param signatureId signing signature ID + * @param signature signing signature value + */ + function _checkAndSetWaveMintSignature( + address wallet, + uint256 amount, + uint256 waveIndex, + uint256 signatureId, + bytes calldata signature + ) internal { + NFTCollectionSignatureStorage storage $ = _getNFTCollectionSignatureStorage(); + if ($.signatureIds[signatureId] != SignatureType.Unused + || _getWaveMintSignature(wallet, amount, waveIndex, signatureId, address(this), block.chainid, signature) != $.signAddress) { + revert InvalidSignature(signatureId); + } + $.signatureIds[signatureId] = SignatureType.WaveMint; + } + + /** + * @notice checks that the provided signature is valid, while also taking into + * consideration the provided address and signatureId. + * @param wallet address to be used in validating the signature + * @param signatureId signing signature ID + * @param signature signing signature value + */ + function _checkAndSetRevealSignature( + address wallet, + uint256 signatureId, + bytes calldata signature + ) internal { + NFTCollectionSignatureStorage storage $ = _getNFTCollectionSignatureStorage(); + if ($.signatureIds[signatureId] != SignatureType.Unused + || _getRevealSignature(wallet, signatureId, address(this), block.chainid, signature) != $.signAddress) { + revert InvalidSignature(signatureId); + } + $.signatureIds[signatureId] = SignatureType.Reveal; + } + + + /** + * @notice checks that the provided personalization signature is valid, while also taking into + * consideration the provided address and signatureId. + * @param wallet address to be used in validating the signature + * @param signatureId signing signature ID + * @param signature signing signature value + * @param tokenId what token to personalize + * @param personalizationMask a mask where each bit has a custom meaning in-game + */ + function _checkAndSetPersonalizationSignature( + address wallet, + uint256 tokenId, + uint256 personalizationMask, + uint256 signatureId, + bytes calldata signature + ) internal { + NFTCollectionSignatureStorage storage $ = _getNFTCollectionSignatureStorage(); + if ($.signatureIds[signatureId] != SignatureType.Unused || + _getPersonalizationSignature( + wallet, + signatureId, + address(this), + block.chainid, + tokenId, + personalizationMask, + signature + ) != $.signAddress) { + revert InvalidSignature(signatureId); + } + $.signatureIds[signatureId] = SignatureType.Personalization; + } + + + /** + * @notice validates signature + * @param wallet wallet that was used in signature generation + * @param signatureId id of signature + * @param contractAddress contract address that was used in signature generation + * @param chainId chain ID for which the signature was generated + * @param signature signature + * @return address that validates the provided signature + */ + function _getMintSignature( + address wallet, + uint256 signatureId, + address contractAddress, + uint256 chainId, + bytes calldata signature + ) internal pure returns (address) { + return + ECDSA.recover( + keccak256( + abi.encodePacked( + "\x19Ethereum Signed Message:\n32", + keccak256(abi.encode(wallet, signatureId, contractAddress, chainId)) + ) + ), + signature + ); + } + + /** + * @notice validates signature + * @param wallet wallet that was used in signature generation + * @param signatureId id of signature + * @param contractAddress contract address that was used in signature generation + * @param chainId chain ID for which the signature was generated + * @param signature signature + * @return address that validates the provided signature + */ + function _getRevealSignature( + address wallet, + uint256 signatureId, + address contractAddress, + uint256 chainId, + bytes calldata signature + ) internal pure returns (address) { + /// @dev the string "reveal" is to distinguish it from the minting signature. + return + ECDSA.recover( + keccak256( + abi.encodePacked( + "\x19Ethereum Signed Message:\n32", + keccak256(abi.encode(wallet, signatureId, contractAddress, chainId, "reveal")) + ) + ), + signature + ); + } + + /** + * @notice validate personalization mask + * @param wallet wallet that was used in signature generation + * @param signatureId id of signature + * @param contractAddress contract address that was used in signature generation + * @param chainId chain ID for which the signature was generated + * @param tokenId token ID for which the signature was generated + * @param personalizationMask a mask where each bit has a custom meaning in-game + * @param signature signature + * @return address that validates the provided signature + */ + function _getPersonalizationSignature( + address wallet, + uint256 signatureId, + address contractAddress, + uint256 chainId, + uint256 tokenId, + uint256 personalizationMask, + bytes calldata signature + ) internal pure returns (address) { + return + ECDSA.recover( + keccak256( + abi.encodePacked( + "\x19Ethereum Signed Message:\n32", + keccak256( + abi.encode( + wallet, + signatureId, + contractAddress, + chainId, + tokenId, + personalizationMask + ) + ) + ) + ), + signature + ); + } + + /** + * @notice validate a mint signature that includes a waveIndex + * @param wallet wallet that was used in signature generation + * @param amount number of token to mint + * @param waveIndex the index of the wave that is used to mint + * @param signatureId id of signature + * @param contractAddress contract address that was used in signature generation + * @param chainId chain ID for which the signature was generated + * @param signature signature + * @return address that validates the provided signature + */ + function _getWaveMintSignature( + address wallet, + uint256 amount, + uint256 waveIndex, + uint256 signatureId, + address contractAddress, + uint256 chainId, + bytes calldata signature + ) internal pure returns (address) { + return + ECDSA.recover( + keccak256( + abi.encodePacked( + "\x19Ethereum Signed Message:\n32", + keccak256( + abi.encode( + wallet, + amount, + waveIndex, + signatureId, + contractAddress, + chainId + ) + ) + ) + ), + signature + ); + } + + /** + * @notice ERC2771 compatible msg.sender getter + * @return sender msg.sender + */ + function _msgSender() internal view virtual returns (address); +} diff --git a/packages/avatar/contracts/nft-collection/UpdatableOperatorFiltererUpgradeable.sol b/packages/avatar/contracts/nft-collection/UpdatableOperatorFiltererUpgradeable.sol index 3b22586ad3..7236c50113 100644 --- a/packages/avatar/contracts/nft-collection/UpdatableOperatorFiltererUpgradeable.sol +++ b/packages/avatar/contracts/nft-collection/UpdatableOperatorFiltererUpgradeable.sol @@ -2,9 +2,6 @@ // solhint-disable one-contract-per-file pragma solidity 0.8.26; -import {Initializable} from "@openzeppelin/contracts-upgradeable-5.0.2/proxy/utils/Initializable.sol"; -import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable-5.0.2/utils/ContextUpgradeable.sol"; - /** * @title UpdatableOperatorFiltererUpgradeable * @author The Sandbox @@ -16,7 +13,7 @@ import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable-5.0.2/util * and adapted to the 0.5.9 solidity version * To avoid an extra IOperatorFilterRegistry file for a code that is deprecated the interface is added below */ -abstract contract UpdatableOperatorFiltererUpgradeable is Initializable, ContextUpgradeable { +abstract contract UpdatableOperatorFiltererUpgradeable { struct UpdatableOperatorFiltererUpgradeableStorage { /** * @notice the registry filter @@ -163,6 +160,12 @@ abstract contract UpdatableOperatorFiltererUpgradeable is Initializable, Context revert OperatorNotAllowed(operator); } } + + /** + * @notice ERC2771 compatible msg.sender getter + * @return sender msg.sender + */ + function _msgSender() internal view virtual returns (address); } /** diff --git a/packages/avatar/test/avatar/nft-collection/NFTCollection.batch.mint.test.ts b/packages/avatar/test/avatar/nft-collection/NFTCollection.batch.mint.test.ts index e856f5bf0f..de63b25ae4 100644 --- a/packages/avatar/test/avatar/nft-collection/NFTCollection.batch.mint.test.ts +++ b/packages/avatar/test/avatar/nft-collection/NFTCollection.batch.mint.test.ts @@ -12,11 +12,11 @@ describe('NFTCollection batch mint', function () { setupDefaultWave, } = await loadFixture(setupNFTCollectionContract); await setupDefaultWave(20); - await contract.batchMint([ + await contract.batchMint(0, [ [randomWallet, 7], [collectionOwner, 5], ]); - const indexWave = await contract.indexWave(); + const indexWave = await contract.waveCount(); expect( await contract.waveOwnerToClaimedCounts(indexWave - 1n, randomWallet) ).to.be.eq(7); @@ -40,7 +40,7 @@ describe('NFTCollection batch mint', function () { it('other should not be able batchMint', async function () { const {collectionContractAsRandomWallet: contract, randomWallet} = await loadFixture(setupNFTCollectionContract); - await expect(contract.batchMint([[randomWallet, 1]])) + await expect(contract.batchMint(0, [[randomWallet, 1]])) .to.revertedWithCustomError(contract, 'OwnableUnauthorizedAccount') .withArgs(randomWallet); }); @@ -49,7 +49,7 @@ describe('NFTCollection batch mint', function () { const {collectionContractAsOwner: contract, randomWallet} = await loadFixture(setupNFTCollectionContract); await expect( - contract.batchMint([[randomWallet, 1]]) + contract.batchMint(0, [[randomWallet, 1]]) ).to.revertedWithCustomError(contract, 'ContractNotConfigured'); }); @@ -58,7 +58,7 @@ describe('NFTCollection batch mint', function () { const {collectionContractAsOwner: contract, setupDefaultWave} = await loadFixture(setupNFTCollectionContract); await setupDefaultWave(20); - await expect(contract.batchMint([])).to.revertedWithCustomError( + await expect(contract.batchMint(0, [])).to.revertedWithCustomError( contract, 'InvalidBatchData' ); @@ -72,7 +72,7 @@ describe('NFTCollection batch mint', function () { } = await loadFixture(setupNFTCollectionContract); await setupDefaultWave(20); await expect( - contract.batchMint([ + contract.batchMint(0, [ [randomWallet, 1], [randomWallet, 0], ]) @@ -87,7 +87,7 @@ describe('NFTCollection batch mint', function () { const waveMaxTokensOverall = 100; await contract.setupWave(waveMaxTokensOverall, 10, 20); await expect( - contract.batchMint([[randomWallet, waveMaxTokensOverall + 1]]) + contract.batchMint(0, [[randomWallet, waveMaxTokensOverall + 1]]) ) .to.revertedWithCustomError(contract, 'CannotMint') .withArgs(randomWallet, waveMaxTokensOverall + 1); @@ -99,7 +99,7 @@ describe('NFTCollection batch mint', function () { const waveMaxTokensPerWallet = 10; await contract.setupWave(100, waveMaxTokensPerWallet, 0); await expect( - contract.batchMint([[randomWallet, waveMaxTokensPerWallet + 1]]) + contract.batchMint(0, [[randomWallet, waveMaxTokensPerWallet + 1]]) ) .to.revertedWithCustomError(contract, 'CannotMint') .withArgs(randomWallet, waveMaxTokensPerWallet + 1); @@ -112,9 +112,9 @@ describe('NFTCollection batch mint', function () { maxSupply, } = await loadFixture(setupNFTCollectionContract); await contract.setupWave(maxSupply, maxSupply, 0); - await contract.batchMint([[randomWallet, maxSupply]]); + await contract.batchMint(0, [[randomWallet, maxSupply]]); await contract.setupWave(maxSupply, maxSupply, 0); - await expect(contract.batchMint([[randomWallet, maxSupply]])) + await expect(contract.batchMint(0, [[randomWallet, maxSupply]])) .to.revertedWithCustomError(contract, 'CannotMint') .withArgs(randomWallet, maxSupply); }); diff --git a/packages/avatar/test/avatar/nft-collection/NFTCollection.burn.test.ts b/packages/avatar/test/avatar/nft-collection/NFTCollection.burn.test.ts index 8b164c6808..007f66d903 100644 --- a/packages/avatar/test/avatar/nft-collection/NFTCollection.burn.test.ts +++ b/packages/avatar/test/avatar/nft-collection/NFTCollection.burn.test.ts @@ -52,7 +52,7 @@ describe('NFTCollection burn', function () { await setupDefaultWave(20); // skip 5 ids - await contract.batchMint([[randomWallet, 5]]); + await contract.batchMint(0, [[randomWallet, 5]]); // enable burning await expect(contract.burn(1)).to.revertedWithCustomError( @@ -62,7 +62,7 @@ describe('NFTCollection burn', function () { await contract.enableBurning(); // mint 5 - await contract.batchMint([[randomWallet, 5]]); + await contract.batchMint(0, [[randomWallet, 5]]); const transferEvents = await contract.queryFilter('Transfer'); for (let i = 0; i < transferEvents.length; i++) { const tokenId = transferEvents[i].args.tokenId; @@ -100,7 +100,7 @@ describe('NFTCollection burn', function () { await contract.enableBurning(); // mint 5 - await contract.batchMint([[randomWallet2, 5]]); + await contract.batchMint(0, [[randomWallet2, 5]]); const transferEvents = await contract.queryFilter('Transfer'); for (let i = 0; i < transferEvents.length; i++) { @@ -135,7 +135,7 @@ describe('NFTCollection burn', function () { await contract.enableBurning(); // mint 5 - await contract.batchMint([[randomWallet, 5]]); + await contract.batchMint(0, [[randomWallet, 5]]); const transferEvents = await contract.queryFilter('Transfer'); for (let i = 0; i < transferEvents.length; i++) { diff --git a/packages/avatar/test/avatar/nft-collection/NFTCollection.config.test.ts b/packages/avatar/test/avatar/nft-collection/NFTCollection.config.test.ts index df33a472c2..b696150c84 100644 --- a/packages/avatar/test/avatar/nft-collection/NFTCollection.config.test.ts +++ b/packages/avatar/test/avatar/nft-collection/NFTCollection.config.test.ts @@ -195,7 +195,11 @@ describe('NFTCollection config', function () { ).to.revertedWithCustomError(contract, 'EnforcedPause'); await expect( - contract.batchMint([[randomWallet, 1]]) + contract.waveMint(randomWallet, 10, 0, 1, '0x') + ).to.revertedWithCustomError(contract, 'EnforcedPause'); + + await expect( + contract.batchMint(0, [[randomWallet, 1]]) ).to.revertedWithCustomError(contract, 'EnforcedPause'); await expect(contract.reveal(1, 1, '0x')).to.revertedWithCustomError( @@ -204,7 +208,7 @@ describe('NFTCollection config', function () { ); await expect( - contract.personalize(1, '0x', 1, 1) + contract.personalize(1, 1, 222, '0x') ).to.revertedWithCustomError(contract, 'EnforcedPause'); await expect(contract.burn(1)).to.revertedWithCustomError( @@ -373,7 +377,7 @@ describe('NFTCollection config', function () { await expect(contract.setMaxSupply(totalSupply)) .to.emit(contract, 'MaxSupplySet') .withArgs(nftCollectionAdmin, maxSupply, totalSupply); - await expect(contract.batchMint([[collectionOwner, 1]])) + await expect(contract.batchMint(0, [[collectionOwner, 1]])) .to.revertedWithCustomError(contract, 'CannotMint') .withArgs(collectionOwner, 1); await expect(contract.setMaxSupply(totalSupply - 1n)) diff --git a/packages/avatar/test/avatar/nft-collection/NFTCollection.fixtures.ts b/packages/avatar/test/avatar/nft-collection/NFTCollection.fixtures.ts index 65ebc4625a..4811bd3a30 100644 --- a/packages/avatar/test/avatar/nft-collection/NFTCollection.fixtures.ts +++ b/packages/avatar/test/avatar/nft-collection/NFTCollection.fixtures.ts @@ -44,11 +44,7 @@ export async function setupNFTCollectionContract() { ).deploy(); const collectionContractAsOwner = collectionContract.connect(collectionOwner); - const authSign = setupSignAuthMessageAs( - collectionContract, - accounts.raffleSignWallet - ); - + const mintSign = setupMintSign(collectionContract, accounts.raffleSignWallet); const NFTCollectionMock = await ethers.getContractFactory( 'NFTCollectionMock' ); @@ -110,17 +106,19 @@ export async function setupNFTCollectionContract() { setupDefaultWave, mint: async (amount, wallet = collectionOwner) => { await setupDefaultWave(0); - await collectionContractAsOwner.batchMint([[wallet, amount]]); + await collectionContractAsOwner.batchMint(0, [[wallet, amount]]); const transferEvents = await collectionContractAsOwner.queryFilter( 'Transfer' ); return transferEvents.map((x) => x.args.tokenId); }, - authSign, + mintSign, + waveMintSign: setupWaveSign(collectionContract, accounts.raffleSignWallet), personalizeSignature: setupPersonalizeSign( collectionContract, accounts.raffleSignWallet ), + revealSig: setupRevealSign(collectionContract, accounts.raffleSignWallet), initializeArgs, deployWithCustomArg: async (idx: number, val) => { const args = getCustomArgs(idx, val); @@ -132,7 +130,7 @@ export async function setupNFTCollectionContract() { }; } -function setupSignAuthMessageAs(contract: Contract, raffleSignWallet: Signer) { +function setupMintSign(contract: Contract, raffleSignWallet: Signer) { return async ( destinationWallet: AddressLike, signatureId: BigNumberish, @@ -163,6 +161,77 @@ function setupSignAuthMessageAs(contract: Contract, raffleSignWallet: Signer) { }; } +function setupWaveSign(contract: Contract, raffleSignWallet: Signer) { + return async ( + destinationWallet: AddressLike, + amount: BigNumberish, + waveIndex: BigNumberish, + signatureId: BigNumberish, + signerWallet: Signer = raffleSignWallet, + contractAddress: string | undefined = undefined, + chainId: number = network.config.chainId + ) => { + if (!contractAddress) { + contractAddress = await contract.getAddress(); + } + if (destinationWallet instanceof Promise) { + destinationWallet = await destinationWallet; + } + if ( + typeof destinationWallet != 'string' && + 'getAddress' in destinationWallet + ) { + destinationWallet = await destinationWallet.getAddress(); + } + const hashedData = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'uint256', 'uint256', 'address', 'uint256'], + [ + destinationWallet, + amount, + waveIndex, + signatureId, + contractAddress, + chainId, + ] + ); + // https://docs.ethers.org/v6/migrating/ + return signerWallet.signMessage( + ethers.getBytes(ethers.keccak256(hashedData)) + ); + }; +} + +function setupRevealSign(contract: Contract, raffleSignWallet: Signer) { + return async ( + destinationWallet: AddressLike, + signatureId: BigNumberish, + signerWallet: Signer = raffleSignWallet, + contractAddress: string | undefined = undefined, + chainId: number = network.config.chainId + ) => { + if (!contractAddress) { + contractAddress = await contract.getAddress(); + } + if (destinationWallet instanceof Promise) { + destinationWallet = await destinationWallet; + } + if ( + typeof destinationWallet != 'string' && + 'getAddress' in destinationWallet + ) { + destinationWallet = await destinationWallet.getAddress(); + } + const hashedData = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'address', 'uint256', 'string'], + [destinationWallet, signatureId, contractAddress, chainId, 'reveal'] + ); + // https://docs.ethers.org/v6/migrating/ + return signerWallet.signMessage( + ethers.getBytes(ethers.keccak256(hashedData)) + ); + }; +} + function setupPersonalizeSign(contract: Contract, raffleSignWallet: Signer) { return async ( destinationWallet: AddressLike, diff --git a/packages/avatar/test/avatar/nft-collection/NFTCollection.gas.usage.test.ts b/packages/avatar/test/avatar/nft-collection/NFTCollection.gas.usage.test.ts index bb24f0a05a..2be0abe8ef 100644 --- a/packages/avatar/test/avatar/nft-collection/NFTCollection.gas.usage.test.ts +++ b/packages/avatar/test/avatar/nft-collection/NFTCollection.gas.usage.test.ts @@ -25,7 +25,7 @@ async function customDeploy() { const tokenIds = []; while (tokenIds.length < amount) { const batchSize = Math.min(500, amount - tokenIds.length); - const tx = await collectionContractAsOwner.batchMint([ + const tx = await collectionContractAsOwner.batchMint(0, [ [wallet, batchSize], ]); const transferEvents = await collectionContractAsOwner.queryFilter( @@ -74,7 +74,7 @@ describe('NFTCollection gas usage @skip-on-ci @skip-on-coverage', function () { customDeploy ); const amount = 1150n; - const tx = await collectionContractAsOwner.batchMint([ + const tx = await collectionContractAsOwner.batchMint(0, [ [randomWallet, amount], ]); const receipt = await tx.wait(); @@ -95,7 +95,7 @@ describe('NFTCollection gas usage @skip-on-ci @skip-on-coverage', function () { for (let i = 0; i < amount; i++) { batches.push([Wallet.createRandom(), 1]); } - const tx = await collectionContractAsOwner.batchMint(batches); + const tx = await collectionContractAsOwner.batchMint(0, batches); const receipt = await tx.wait(); const gasPerToken = receipt.gasUsed / amount; console.log( @@ -113,14 +113,14 @@ describe('NFTCollection gas usage @skip-on-ci @skip-on-coverage', function () { sandContract, randomWallet, raffleSignWallet, - authSign, + mintSign, } = await loadFixture(customDeploy); const amount = 1120n; const encodedData = contract.interface.encodeFunctionData('mint', [ await randomWallet.getAddress(), amount, 222, - await authSign( + await mintSign( randomWallet, 222, raffleSignWallet, diff --git a/packages/avatar/test/avatar/nft-collection/NFTCollection.mint.test.ts b/packages/avatar/test/avatar/nft-collection/NFTCollection.mint.test.ts index bd5cec288d..f05e6fd65a 100644 --- a/packages/avatar/test/avatar/nft-collection/NFTCollection.mint.test.ts +++ b/packages/avatar/test/avatar/nft-collection/NFTCollection.mint.test.ts @@ -5,279 +5,488 @@ import {ZeroAddress} from 'ethers'; import {loadFixture} from '@nomicfoundation/hardhat-network-helpers'; describe('NFTCollection mint', function () { - it('user should be able to mint with the right signature and payment', async function () { - const { - collectionContractAsOwner: contract, - authSign, - sandContract, - randomWallet, - treasury, - maxSupply, - } = await loadFixture(setupNFTCollectionContract); - const amount = 2; - const unitPrice = 10; - const price = amount * unitPrice; - await sandContract.donateTo(randomWallet, price); - await contract.setupWave(maxSupply, maxSupply, unitPrice); - const encodedData = contract.interface.encodeFunctionData('mint', [ - await randomWallet.getAddress(), - amount, - 222, - await authSign(randomWallet, 222), - ]); - expect(await sandContract.balanceOf(treasury)).to.be.eq(0); - expect(await sandContract.balanceOf(randomWallet)).to.be.eq(price); - expect(await contract.isMintAllowed(randomWallet, amount)).to.be.true; - await sandContract - .connect(randomWallet) - .approveAndCall(contract, price, encodedData); - expect(await sandContract.balanceOf(treasury)).to.be.eq(price); - expect(await sandContract.balanceOf(randomWallet)).to.be.eq(0); - expect(await contract.isSignatureUsed(222)).to.be.true; - const transferEvents = await contract.queryFilter('Transfer'); - for (let i = 0; i < transferEvents.length; i++) { - const tokenId = transferEvents[i].args.tokenId; - expect(await contract.ownerOf(tokenId)).to.be.eq(randomWallet); - } - const indexWave = await contract.indexWave(); - expect( - await contract.waveOwnerToClaimedCounts(indexWave - 1n, randomWallet) - ).to.be.eq(2); - expect(await contract.waveTotalMinted(indexWave - 1n)).to.be.eq(2); - expect(await contract.totalSupply()).to.be.eq(2); - }); - - it('should not be able to mint over waveMaxTokensPerWallet', async function () { - const { - collectionContractAsOwner: contract, - authSign, - sandContract, - randomWallet, - waveMaxTokensOverall, - waveMaxTokensPerWallet, - } = await loadFixture(setupNFTCollectionContract); - await contract.setupWave(waveMaxTokensOverall, waveMaxTokensPerWallet, 0); - await expect( - sandContract.mint( - contract, - randomWallet, - waveMaxTokensPerWallet + 1, - 222, - await authSign(randomWallet, 222) - ) - ) - .to.revertedWithCustomError(contract, 'CannotMint') - .withArgs(randomWallet, waveMaxTokensPerWallet + 1); - }); - - it('should not be able to mint over waveMaxTokensOverall', async function () { - const { - collectionContractAsOwner: contract, - authSign, - sandContract, - randomWallet, - randomWallet2, - waveMaxTokensOverall, - } = await loadFixture(setupNFTCollectionContract); - await contract.setupWave(waveMaxTokensOverall, waveMaxTokensOverall - 1, 0); - await sandContract.mint( - contract, - randomWallet, - waveMaxTokensOverall - 1, - 222, - await authSign(randomWallet, 222) - ); - await expect( - sandContract.mint( - contract, - randomWallet2, - waveMaxTokensOverall - 1, - 223, - await authSign(randomWallet2, 223) - ) - ) - .to.revertedWithCustomError(contract, 'CannotMint') - .withArgs(randomWallet2, waveMaxTokensOverall - 1); - }); - - it('should not be able to mint over maxSupply', async function () { - const { - collectionContractAsOwner: contract, - authSign, - sandContract, - randomWallet, - maxSupply, - } = await loadFixture(setupNFTCollectionContract); - await contract.setupWave(maxSupply, maxSupply, 0); - await sandContract.mint( - contract, - randomWallet, - maxSupply, - 222, - await authSign(randomWallet, 222) - ); - await contract.setupWave(maxSupply, maxSupply, 0); - await expect( - sandContract.mint( - contract, - randomWallet, - maxSupply, - 223, - await authSign(randomWallet, 223) - ) - ) - .to.revertedWithCustomError(contract, 'CannotMint') - .withArgs(randomWallet, maxSupply); - }); - - it('should not be able to mint without enough balance', async function () { - const { - collectionContractAsOwner: contract, - authSign, - sandContract, - randomWallet, - maxSupply, - } = await loadFixture(setupNFTCollectionContract); - const price = 10; - await contract.setupWave(maxSupply, maxSupply, price); - await expect( - sandContract.mint( - contract, + describe('backward compatible mint', function () { + it('user should be able to mint with the right signature and payment', async function () { + const { + collectionContractAsOwner: contract, + mintSign, + sandContract, randomWallet, + treasury, maxSupply, + } = await loadFixture(setupNFTCollectionContract); + const amount = 2; + const unitPrice = 10; + const price = amount * unitPrice; + await sandContract.donateTo(randomWallet, price); + await contract.setupWave(maxSupply, maxSupply, unitPrice); + const encodedData = contract.interface.encodeFunctionData('mint', [ + await randomWallet.getAddress(), + amount, 222, - await authSign(randomWallet, 222) - ) - ).to.revertedWith('ERC20: insufficient allowance'); - const encodedData = contract.interface.encodeFunctionData('mint', [ - await randomWallet.getAddress(), - 1, - 222, - await authSign(randomWallet, 222), - ]); - await expect( - sandContract + await mintSign(randomWallet, 222), + ]); + expect(await sandContract.balanceOf(treasury)).to.be.eq(0); + expect(await sandContract.balanceOf(randomWallet)).to.be.eq(price); + expect(await contract.isMintAllowed(0, randomWallet, amount)).to.be.true; + await sandContract .connect(randomWallet) - .approveAndCall(contract, price, encodedData) - ).to.revertedWith('ERC20: transfer amount exceeds balance'); - }); - - describe('wrong args', function () { - it('should not be able to mint if no wave was initialized', async function () { - const {collectionContractAsRandomWallet: contract, randomWallet} = - await loadFixture(setupNFTCollectionContract); - await expect( - contract.mint(randomWallet, 1, 1, '0x') - ).to.revertedWithCustomError(contract, 'ContractNotConfigured'); - }); - - it('should not be able to mint when the caller is not allowed to execute mint', async function () { - const { - collectionContractAsOwner, - collectionContractAsRandomWallet: contract, - randomWallet, - } = await loadFixture(setupNFTCollectionContract); - - await collectionContractAsOwner.setupWave(10, 1, 2); - await expect(contract.mint(randomWallet, 1, 1, '0x')) - .to.revertedWithCustomError(contract, 'ERC721InvalidSender') - .withArgs(randomWallet); + .approveAndCall(contract, price, encodedData); + expect(await sandContract.balanceOf(treasury)).to.be.eq(price); + expect(await sandContract.balanceOf(randomWallet)).to.be.eq(0); + expect(await contract.getSignatureType(222)).to.be.eq(1); + const transferEvents = await contract.queryFilter('Transfer'); + for (let i = 0; i < transferEvents.length; i++) { + const tokenId = transferEvents[i].args.tokenId; + expect(await contract.ownerOf(tokenId)).to.be.eq(randomWallet); + } + const indexWave = await contract.waveCount(); + expect( + await contract.waveOwnerToClaimedCounts(indexWave - 1n, randomWallet) + ).to.be.eq(2); + expect(await contract.waveTotalMinted(indexWave - 1n)).to.be.eq(2); + expect(await contract.totalSupply()).to.be.eq(2); }); - it('should not be able to mint when wallet address is zero', async function () { + it('should not be able to mint over waveMaxTokensPerWallet', async function () { const { - collectionContractAsOwner, - collectionContractAsRandomWallet: contract, + collectionContractAsOwner: contract, + mintSign, sandContract, - authSign, + randomWallet, + waveMaxTokensOverall, + waveMaxTokensPerWallet, } = await loadFixture(setupNFTCollectionContract); - await collectionContractAsOwner.setupWave(10, 1, 0); + await contract.setupWave(waveMaxTokensOverall, waveMaxTokensPerWallet, 0); await expect( sandContract.mint( contract, - ZeroAddress, - 1, + randomWallet, + waveMaxTokensPerWallet + 1, 222, - await authSign(ZeroAddress, 222) + await mintSign(randomWallet, 222) ) ) - .to.revertedWithCustomError(contract, 'ERC721InvalidReceiver') - .withArgs(ZeroAddress); + .to.revertedWithCustomError(contract, 'CannotMint') + .withArgs(randomWallet, waveMaxTokensPerWallet + 1); }); - it('should not be able to mint when amount is zero', async function () { + it('should not be able to mint over waveMaxTokensOverall', async function () { const { - collectionContractAsOwner, - collectionContractAsRandomWallet: contract, + collectionContractAsOwner: contract, + mintSign, sandContract, randomWallet, - authSign, + randomWallet2, + waveMaxTokensOverall, } = await loadFixture(setupNFTCollectionContract); - await collectionContractAsOwner.setupWave(10, 1, 0); + await contract.setupWave( + waveMaxTokensOverall, + waveMaxTokensOverall - 1, + 0 + ); + await sandContract.mint( + contract, + randomWallet, + waveMaxTokensOverall - 1, + 222, + await mintSign(randomWallet, 222) + ); await expect( sandContract.mint( contract, - randomWallet, - 0, - 222, - await authSign(randomWallet, 222) + randomWallet2, + waveMaxTokensOverall - 1, + 223, + await mintSign(randomWallet2, 223) ) ) .to.revertedWithCustomError(contract, 'CannotMint') - .withArgs(randomWallet, 0); + .withArgs(randomWallet2, waveMaxTokensOverall - 1); }); - }); - describe('signature issues', function () { - it('should not be able to mint when with an invalid signature', async function () { + it('should not be able to mint over maxSupply', async function () { const { - collectionContractAsOwner, - collectionContractAsRandomWallet: contract, + collectionContractAsOwner: contract, + mintSign, sandContract, randomWallet, + maxSupply, } = await loadFixture(setupNFTCollectionContract); - await collectionContractAsOwner.setupWave(10, 1, 2); - await expect(sandContract.mint(contract, randomWallet, 1, 1, '0x')) - .to.revertedWithCustomError(contract, 'ECDSAInvalidSignatureLength') - .withArgs(0); + await contract.setupWave(maxSupply, maxSupply, 0); + await sandContract.mint( + contract, + randomWallet, + maxSupply, + 222, + await mintSign(randomWallet, 222) + ); + await contract.setupWave(maxSupply, maxSupply, 0); + await expect( + sandContract.mint( + contract, + randomWallet, + maxSupply, + 223, + await mintSign(randomWallet, 223) + ) + ) + .to.revertedWithCustomError(contract, 'CannotMint') + .withArgs(randomWallet, maxSupply); }); - it('should not be able to mint when with a wrong signature (signed by wrong address)', async function () { + it('should not be able to mint without enough balance', async function () { const { - collectionContractAsOwner, - collectionContractAsRandomWallet: contract, + collectionContractAsOwner: contract, + mintSign, sandContract, randomWallet, - authSign, + maxSupply, } = await loadFixture(setupNFTCollectionContract); - await collectionContractAsOwner.setupWave(10, 1, 2); + const price = 10; + await contract.setupWave(maxSupply, maxSupply, price); await expect( sandContract.mint( contract, randomWallet, - 1, - 1, - await authSign(randomWallet, 222, randomWallet) + maxSupply, + 222, + await mintSign(randomWallet, 222) ) - ) - .to.revertedWithCustomError(contract, 'InvalidSignature') - .withArgs(1); + ).to.revertedWith('ERC20: insufficient allowance'); + const encodedData = contract.interface.encodeFunctionData('mint', [ + await randomWallet.getAddress(), + 1, + 222, + await mintSign(randomWallet, 222), + ]); + await expect( + sandContract + .connect(randomWallet) + .approveAndCall(contract, price, encodedData) + ).to.revertedWith('ERC20: transfer amount exceeds balance'); }); - it('should not be able to mint when the signature is used twice', async function () { + describe('wrong args', function () { + it('should not be able to mint if no wave was initialized', async function () { + const {collectionContractAsRandomWallet: contract, randomWallet} = + await loadFixture(setupNFTCollectionContract); + await expect( + contract.mint(randomWallet, 1, 1, '0x') + ).to.revertedWithCustomError(contract, 'ContractNotConfigured'); + }); + + it('should not be able to mint when the caller is not allowed to execute mint', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + randomWallet, + } = await loadFixture(setupNFTCollectionContract); + + await collectionContractAsOwner.setupWave(10, 1, 2); + await expect(contract.mint(randomWallet, 1, 1, '0x')) + .to.revertedWithCustomError(contract, 'ERC721InvalidSender') + .withArgs(randomWallet); + }); + + it('should not be able to mint when wallet address is zero', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + mintSign, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 0); + await expect( + sandContract.mint( + contract, + ZeroAddress, + 1, + 222, + await mintSign(ZeroAddress, 222) + ) + ) + .to.revertedWithCustomError(contract, 'ERC721InvalidReceiver') + .withArgs(ZeroAddress); + }); + + it('should not be able to mint when amount is zero', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + randomWallet, + mintSign, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 0); + await expect( + sandContract.mint( + contract, + randomWallet, + 0, + 222, + await mintSign(randomWallet, 222) + ) + ) + .to.revertedWithCustomError(contract, 'CannotMint') + .withArgs(randomWallet, 0); + }); + }); + + describe('signature issues', function () { + it('should not be able to mint when with an invalid signature', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + randomWallet, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 2); + await expect(sandContract.mint(contract, randomWallet, 1, 1, '0x')) + .to.revertedWithCustomError(contract, 'ECDSAInvalidSignatureLength') + .withArgs(0); + }); + + it('should not be able to mint when with a wrong signature (signed by wrong address)', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + randomWallet, + mintSign, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 2); + await expect( + sandContract.mint( + contract, + randomWallet, + 1, + 1, + await mintSign(randomWallet, 222, randomWallet) + ) + ) + .to.revertedWithCustomError(contract, 'InvalidSignature') + .withArgs(1); + }); + + it('should not be able to mint when the signature is used twice', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + randomWallet, + mintSign, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 0); + const signature = await mintSign(randomWallet, 222); + await sandContract.mint(contract, randomWallet, 1, 222, signature); + await expect( + sandContract.mint(contract, randomWallet, 1, 222, signature) + ) + .to.revertedWithCustomError(contract, 'InvalidSignature') + .withArgs(222); + }); + }); + }); + + describe('wave mint', function () { + it('user should be able to waveMint with the right signature and payment', async function () { const { - collectionContractAsOwner, - collectionContractAsRandomWallet: contract, + collectionContractAsOwner: contract, + waveMintSign, sandContract, randomWallet, - authSign, + treasury, + maxSupply, } = await loadFixture(setupNFTCollectionContract); - await collectionContractAsOwner.setupWave(10, 1, 0); - const signature = await authSign(randomWallet, 222); - await sandContract.mint(contract, randomWallet, 1, 222, signature); - await expect(sandContract.mint(contract, randomWallet, 1, 222, signature)) - .to.revertedWithCustomError(contract, 'InvalidSignature') - .withArgs(222); + const unitPrice = 10; + await sandContract.donateTo(randomWallet, unitPrice * 5); + // Setup two waves + await contract.setupWave(maxSupply, maxSupply, unitPrice); + await contract.setupWave(maxSupply, maxSupply, unitPrice); + expect(await sandContract.balanceOf(treasury)).to.be.eq(0); + expect(await sandContract.balanceOf(randomWallet)).to.be.eq( + unitPrice * 5 + ); + + // buy 2 from first wave + const encodedData0 = contract.interface.encodeFunctionData('waveMint', [ + await randomWallet.getAddress(), + 2, + 0, + 222, + await waveMintSign(randomWallet, 2, 0, 222), + ]); + // buy 3 from second wave + const encodedData1 = contract.interface.encodeFunctionData('waveMint', [ + await randomWallet.getAddress(), + 3, + 1, + 223, + await waveMintSign(randomWallet, 3, 1, 223), + ]); + + await sandContract + .connect(randomWallet) + .approveAndCall(contract, unitPrice * 2, encodedData0); + await sandContract + .connect(randomWallet) + .approveAndCall(contract, unitPrice * 3, encodedData1); + + expect(await sandContract.balanceOf(treasury)).to.be.eq(unitPrice * 5); + expect(await sandContract.balanceOf(randomWallet)).to.be.eq(0); + expect(await contract.waveOwnerToClaimedCounts(0, randomWallet)).to.be.eq( + 2 + ); + expect(await contract.waveTotalMinted(0)).to.be.eq(2); + expect(await contract.waveOwnerToClaimedCounts(1, randomWallet)).to.be.eq( + 3 + ); + expect(await contract.waveTotalMinted(1)).to.be.eq(3); + expect(await contract.totalSupply()).to.be.eq(5); + + expect(await contract.waveOwnerToClaimedCounts(0, randomWallet)).to.be.eq( + 2 + ); + expect(await contract.waveOwnerToClaimedCounts(1, randomWallet)).to.be.eq( + 3 + ); + + expect(await contract.getSignatureType(222)).to.be.eq(4); + expect(await contract.getSignatureType(223)).to.be.eq(4); + }); + + describe('wrong args', function () { + it('should not be able to waveMint if no wave was initialized', async function () { + const {collectionContractAsRandomWallet: contract, randomWallet} = + await loadFixture(setupNFTCollectionContract); + await expect( + contract.waveMint(randomWallet, 1, 0, 1, '0x') + ).to.revertedWithCustomError(contract, 'ContractNotConfigured'); + }); + + it('should not be able to waveMint when the caller is not allowed to execute mint', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + randomWallet, + } = await loadFixture(setupNFTCollectionContract); + + await collectionContractAsOwner.setupWave(10, 1, 2); + await expect(contract.waveMint(randomWallet, 1, 0, 1, '0x')) + .to.revertedWithCustomError(contract, 'ERC721InvalidSender') + .withArgs(randomWallet); + }); + + it('should not be able to waveMint when wallet address is zero', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + waveMintSign, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 0); + await expect( + sandContract.waveMint( + contract, + ZeroAddress, + 1, + 0, + 222, + await waveMintSign(ZeroAddress, 1, 0, 222) + ) + ) + .to.revertedWithCustomError(contract, 'ERC721InvalidReceiver') + .withArgs(ZeroAddress); + }); + + it('should not be able to waveMint when amount is zero', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + randomWallet, + waveMintSign, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 0); + await expect( + sandContract.waveMint( + contract, + randomWallet, + 0, + 0, + 222, + await waveMintSign(randomWallet, 0, 0, 222) + ) + ) + .to.revertedWithCustomError(contract, 'CannotMint') + .withArgs(randomWallet, 0); + }); + }); + + describe('signature issues', function () { + it('should not be able to waveMint when with an invalid signature', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + randomWallet, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 2); + await expect( + sandContract.waveMint(contract, randomWallet, 1, 0, 1, '0x') + ) + .to.revertedWithCustomError(contract, 'ECDSAInvalidSignatureLength') + .withArgs(0); + }); + + it('should not be able to waveMint when with a wrong signature (signed by wrong address)', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + randomWallet, + mintSign, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 2); + await expect( + sandContract.waveMint( + contract, + randomWallet, + 1, + 0, + 1, + await mintSign(randomWallet, 222, randomWallet) + ) + ) + .to.revertedWithCustomError(contract, 'InvalidSignature') + .withArgs(1); + }); + + it('should not be able to waveMint when the signature is used twice', async function () { + const { + collectionContractAsOwner, + collectionContractAsRandomWallet: contract, + sandContract, + randomWallet, + waveMintSign, + } = await loadFixture(setupNFTCollectionContract); + await collectionContractAsOwner.setupWave(10, 1, 0); + const signature = await waveMintSign(randomWallet, 1, 0, 222); + await sandContract.waveMint( + contract, + randomWallet, + 1, + 0, + 222, + signature + ); + await expect( + sandContract.waveMint(contract, randomWallet, 1, 0, 222, signature) + ) + .to.revertedWithCustomError(contract, 'InvalidSignature') + .withArgs(222); + }); }); }); }); diff --git a/packages/avatar/test/avatar/nft-collection/NFTCollection.test.ts b/packages/avatar/test/avatar/nft-collection/NFTCollection.test.ts index 44e5711d8e..837c3203e0 100644 --- a/packages/avatar/test/avatar/nft-collection/NFTCollection.test.ts +++ b/packages/avatar/test/avatar/nft-collection/NFTCollection.test.ts @@ -9,14 +9,14 @@ describe('NFTCollection', function () { const { collectionContractAsRandomWallet: contract, randomWallet, - authSign, + revealSig, setupDefaultWave, mint, } = await loadFixture(setupNFTCollectionContract); await setupDefaultWave(0); const tokenIds = await mint(1, randomWallet); await expect( - contract.reveal(tokenIds[0], 222, await authSign(randomWallet, 222)) + contract.reveal(tokenIds[0], 222, await revealSig(randomWallet, 222)) ) .to.emit(contract, 'MetadataUpdate') .withArgs(tokenIds[0]); @@ -55,7 +55,7 @@ describe('NFTCollection', function () { const { collectionContractAsOwner: contract, randomWallet, - authSign, + mintSign, setupDefaultWave, mint, } = await loadFixture(setupNFTCollectionContract); @@ -65,7 +65,7 @@ describe('NFTCollection', function () { contract.reveal( tokenIds[0], 222, - await authSign(randomWallet, 222, randomWallet) + await mintSign(randomWallet, 222, randomWallet) ) ) .to.revertedWithCustomError(contract, 'InvalidSignature') @@ -76,13 +76,13 @@ describe('NFTCollection', function () { const { collectionContractAsOwner: contract, collectionOwner, - authSign, + revealSig, setupDefaultWave, mint, } = await loadFixture(setupNFTCollectionContract); await setupDefaultWave(0); const tokenIds = await mint(1); - const signature = await authSign(collectionOwner, 222); + const signature = await revealSig(collectionOwner, 222); await contract.reveal(tokenIds[0], 222, signature); await expect(contract.reveal(tokenIds[0], 222, signature)) .to.revertedWithCustomError(contract, 'InvalidSignature') @@ -104,15 +104,15 @@ describe('NFTCollection', function () { const tokenIds = await mint(1, randomWallet); const personalizationMask = '0x123456789abcdef0'; const tx = contract.personalize( + tokenIds[0], + personalizationMask, 222, await personalizeSignature( randomWallet, tokenIds[0], personalizationMask, 222 - ), - tokenIds[0], - personalizationMask + ) ); await expect(tx) .to.emit(contract, 'Personalized') @@ -136,7 +136,7 @@ describe('NFTCollection', function () { await setupDefaultWave(0); const tokenIds = await mint(1, randomWallet); await expect( - contract.personalize(222, '0x', tokenIds[0], '0x123456789abcdef0') + contract.personalize(tokenIds[0], '0x123456789abcdef0', 222, '0x') ) .to.revertedWithCustomError(contract, 'ERC721IncorrectOwner') .withArgs(collectionOwner, tokenIds[0], randomWallet); @@ -152,7 +152,7 @@ describe('NFTCollection', function () { await setupDefaultWave(0); const tokenIds = await mint(1); await expect( - contract.personalize(222, '0x', tokenIds[0], '0x123456789abcdef0') + contract.personalize(tokenIds[0], '0x123456789abcdef0', 222, '0x') ) .to.revertedWithCustomError(contract, 'ECDSAInvalidSignatureLength') .withArgs(0); @@ -170,6 +170,8 @@ describe('NFTCollection', function () { const tokenIds = await mint(1); await expect( contract.personalize( + tokenIds[0], + '0x123456789abcdef0', 222, await personalizeSignature( randomWallet, @@ -177,9 +179,7 @@ describe('NFTCollection', function () { '0x123456789abcdef0', 222, randomWallet - ), - tokenIds[0], - '0x123456789abcdef0' + ) ) ) .to.revertedWithCustomError(contract, 'InvalidSignature') @@ -203,17 +203,17 @@ describe('NFTCollection', function () { 222 ); await contract.personalize( - 222, - signature, tokenIds[0], - '0x123456789abcdef0' + '0x123456789abcdef0', + 222, + signature ); await expect( contract.personalize( - 222, - signature, tokenIds[0], - '0x123456789abcdef0' + '0x123456789abcdef0', + 222, + signature ) ) .to.revertedWithCustomError(contract, 'InvalidSignature') @@ -351,7 +351,7 @@ describe('NFTCollection', function () { mockERC20, collectionOwner, maxSupply, - authSign, + mintSign, randomWallet, raffleSignWallet, deployWithCustomArg, @@ -368,8 +368,43 @@ describe('NFTCollection', function () { await randomWallet.getAddress(), 12, 222, - await authSign( + await mintSign( + randomWallet, + 222, + raffleSignWallet, + await contract.getAddress() + ) + ) + ).to.be.revertedWithCustomError(contract, 'ReentrancyGuardReentrantCall'); + }); + + it('should not be able to reenter waveMint', async function () { + const { + mockERC20, + collectionOwner, + maxSupply, + waveMintSign, + randomWallet, + raffleSignWallet, + deployWithCustomArg, + } = await loadFixture(setupNFTCollectionContract); + const contract = await deployWithCustomArg( + 7, + await mockERC20.getAddress() + ); + const contractAsOwner = contract.connect(collectionOwner); + await contractAsOwner.setupWave(maxSupply, maxSupply, 1); + await expect( + mockERC20.waveMintReenter( + contract, + await randomWallet.getAddress(), + 12, + 0, + 222, + await waveMintSign( randomWallet, + 12, + 0, 222, raffleSignWallet, await contract.getAddress() @@ -385,7 +420,7 @@ describe('NFTCollection', function () { maxSupply, deployer, sandContract, - authSign, + mintSign, } = await loadFixture(setupNFTCollectionContract); const nftPriceInSand = 1; await sandContract.donateTo(deployer, maxSupply); @@ -428,7 +463,7 @@ describe('NFTCollection', function () { await deployer.getAddress(), mintingBatch, signatureId, - await authSign(deployer, signatureId), + await mintSign(deployer, signatureId), ]) ); const transferEvents = await contract.queryFilter( @@ -468,5 +503,10 @@ describe('NFTCollection', function () { expect(slots.nftCollection).to.be.equal( getStorageSlotJS('thesandbox.storage.avatar.nft-collection.NFTCollection') ); + expect(slots.nftCollectionSignature).to.be.equal( + getStorageSlotJS( + 'thesandbox.storage.avatar.nft-collection.NFTCollectionSignature' + ) + ); }); }); diff --git a/packages/avatar/test/avatar/nft-collection/NFTCollection.wave.setup.test.ts b/packages/avatar/test/avatar/nft-collection/NFTCollection.wave.setup.test.ts index 4d8e615783..a752027547 100644 --- a/packages/avatar/test/avatar/nft-collection/NFTCollection.wave.setup.test.ts +++ b/packages/avatar/test/avatar/nft-collection/NFTCollection.wave.setup.test.ts @@ -60,17 +60,56 @@ describe('NFTCollection wave setup', function () { }); }); + describe('cancel', function () { + it('owner should be able to cancel a wave', async function () { + const {collectionContractAsOwner: contract, randomWallet} = + await loadFixture(setupNFTCollectionContract); + await contract.setupWave(10, 1, 0); + expect(await contract.isMintAllowed(0, randomWallet, 1)).to.be.true; + await contract.cancelWave(0); + expect(await contract.isMintAllowed(0, randomWallet, 1)).to.be.false; + }); + + it('should fail to cancel a wave that is not configured', async function () { + const {collectionContractAsOwner: contract} = await loadFixture( + setupNFTCollectionContract + ); + await expect(contract.cancelWave(0)).to.revertedWithCustomError( + contract, + 'ContractNotConfigured' + ); + await expect(contract.cancelWave(10)).to.revertedWithCustomError( + contract, + 'ContractNotConfigured' + ); + }); + + it('other should fail to cancel a wave', async function () { + const {collectionContractAsRandomWallet: contract, randomWallet} = + await loadFixture(setupNFTCollectionContract); + await expect(contract.cancelWave(0)) + .to.revertedWithCustomError(contract, 'OwnableUnauthorizedAccount') + .withArgs(randomWallet); + }); + }); + + it('should not be able to mint on a wave that is not configured', async function () { + const {collectionContractAsOwner: contract, randomWallet} = + await loadFixture(setupNFTCollectionContract); + expect(await contract.isMintAllowed(0, randomWallet, 10)).to.be.false; + }); + it('index should be incremented, total minted should be set to zero on wave setup', async function () { const { collectionContractAsOwner: contract, nftCollectionAdmin, randomWallet, - authSign, + mintSign, sandContract, waveMaxTokensOverall, waveMaxTokensPerWallet, } = await loadFixture(setupNFTCollectionContract); - expect(await contract.indexWave()).to.be.eq(0); + expect(await contract.waveCount()).to.be.eq(0); await expect( contract.setupWave(waveMaxTokensOverall, waveMaxTokensPerWallet, 0) ) @@ -83,8 +122,8 @@ describe('NFTCollection wave setup', function () { 0 ); expect(await contract.waveTotalMinted(0)).to.be.eq(0); - expect(await contract.indexWave()).to.be.eq(1); - await contract.batchMint([ + expect(await contract.waveCount()).to.be.eq(1); + await contract.batchMint(0, [ [randomWallet, 5], [randomWallet, 1], ]); @@ -93,14 +132,19 @@ describe('NFTCollection wave setup', function () { randomWallet, 2, 222, - await authSign(randomWallet, 222) + await mintSign(randomWallet, 222) ); expect(await contract.waveTotalMinted(0)).to.be.eq(5 + 1 + 2); - expect(await contract.indexWave()).to.be.eq(1); + expect(await contract.waveTotalMinted(2n ** 256n - 1n)).to.be.eq(5 + 1 + 2); + expect(await contract.waveTotalMinted(1)).to.be.eq(5 + 1 + 2); + expect(await contract.waveCount()).to.be.eq(1); await expect(contract.setupWave(10, 1, 2)) .to.emit(contract, 'WaveSetup') .withArgs(nftCollectionAdmin, 10, 1, 2, 1); + expect(await contract.waveTotalMinted(0)).to.be.eq(5 + 1 + 2); expect(await contract.waveTotalMinted(1)).to.be.eq(0); - expect(await contract.indexWave()).to.be.eq(2); + expect(await contract.waveTotalMinted(2n ** 256n - 1n)).to.be.eq(0); + expect(await contract.waveTotalMinted(2)).to.be.eq(0); + expect(await contract.waveCount()).to.be.eq(2); }); });