diff --git a/Readme.md b/Readme.md index 4261b9e..f0c56d0 100644 --- a/Readme.md +++ b/Readme.md @@ -73,7 +73,7 @@ - **Lead reviewer** Daniel Luca ([@cleanunicorn](https://twitter.com/cleanunicorn)) - **Reviewers** Daniel Luca ([@cleanunicorn](https://twitter.com/cleanunicorn)), Andrei Simion ([@andreiashu](https://twitter.com/andreiashu)) - **Repository**: [Casinoverse Land](git@github.com:casinometaverse/casinoverse-land.git) -- **Commit hash** `91140addb06bf9df3ad3ed38ea0509406c0eafb9` +- **Commit hash** `bf607afe5ff68b91990649567347f333bb27f6be` - **Technologies** - Solidity - Node.JS @@ -103,7 +103,7 @@ The second week was ... ## Scope -The initial review focused on the [Casinoverse Land](git@github.com:casinometaverse/casinoverse-land.git) repository, identified by the commit hash `91140addb06bf9df3ad3ed38ea0509406c0eafb9`. +The initial review focused on the [Casinoverse Land](git@github.com:casinometaverse/casinoverse-land.git) repository, identified by the commit hash `bf607afe5ff68b91990649567347f333bb27f6be`. We focused on manually reviewing the codebase, searching for security issues such as, but not limited to, re-entrancy problems, transaction ordering, block timestamp dependency, exception handling, call stack depth limitation, integer overflow/underflow, self-destructible contracts, unsecured balance, use of origin, costly gas patterns, architectural problems, code readability. diff --git a/code/contracts/Land.sol b/code/contracts/Land.sol index a2a584e..958f22e 100644 --- a/code/contracts/Land.sol +++ b/code/contracts/Land.sol @@ -2,43 +2,255 @@ pragma solidity ^0.8.7; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/common/ERC2981.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/Pausable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; -contract Land is ERC721, ReentrancyGuard, Ownable, Pausable { +contract Land is ERC721, ERC2981, ReentrancyGuard, Ownable, Pausable { + /** + * @dev Passed on single mint modifier + */ + uint8 constant SINGLE_MINT_QTY = 1; + + /** + * @notice Maximum NFT supply + * + * @dev Maximum NFT that can be minted + */ + // **NOTE: MAX_SUPPLY value should be change to 10000 before mainnet deployment + uint256 public constant MAX_SUPPLY = 20; + + /** + * @notice Minting price + * + * @dev Minting price in ether + * + */ + // **NOTE: mintPrice value should be change before mainnet deployment uint256 public mintPrice = .001 ether; - // Should be change before deploying to mainnet - uint256 public constant MAX_SUPPLY = 20; + /** + * @notice Total supply + * + * @dev Total of tokens minted + */ uint256 public totalMinted = 0; - address public withdrawalRecipient = - 0x82eB3a9403361b7AD0A662FB13eac1F82BA1e4FB; - bytes32 merkleRoot = - 0x84e49f262788fc6e60aae4f375e63daf197871e6bbf19227657d4e7754db7d57; + + /** + * @notice Withdrawal recipient address + * + * @dev Address to send contract's ether balance + * + */ + // **NOTE: withdrawalRecipient value should be change before mainnet deployment + address public withdrawalRecipient = 0x82eB3a9403361b7AD0A662FB13eac1F82BA1e4FB; + + /** + * @notice Merkle root + * + * @dev Should be set by admin after whitelising + * + */ + // **NOTE: merkleRoot value should be change before mainnet deployment + bytes32 merkleRoot = 0x84e49f262788fc6e60aae4f375e63daf197871e6bbf19227657d4e7754db7d57; + + /** + * @notice Contract-level metadata + * + * @dev Field is declared for opensea contract-level metadata + * for on-chain royalty see https://docs.opensea.io/docs/contract-level-metadata + */ + string public contractURI = ""; + + /** + * @notice Base token URI of nft + * + * @dev If baseURIMode is true, all token id will use this as their token URI + * + */ string public baseTokenURI = ""; + + /** + * @notice Suffix of baseTokenURI + * + */ string public tokenURISuffix = ".json"; - string public previewURI = - "https://arweave.net/OEpdYUZsk9vAteHHxvD-cwgl8BhM3m8Yp_E8f6TltLI/"; - uint16 public lastTokenSoldAfterCutoff; + /** + * @notice Mode to determine if tokenURI will return tokenIdURI or baseTokenURI + * + * @dev Should be set to true after all nft is minted + */ + bool public baseURIMode = false; + + /** + * @notice Maximum nft amount an address can hold + * + */ + uint16 maxMintPerAddress = 5; + + /** + * @notice This is for individual uri of tokens + * + * @dev Maps token ID => token URI + */ + mapping(uint256 => string) tokenIdURI; + + /** + * @notice Address to receive EIP-2981 royalties from secondary sales + * see https://eips.ethereum.org/EIPS/eip-2981 + * + */ + // **NOTE: royaltyReceiverAddress value should be replace before mainnet deployment + address public royaltyReceiverAddress = 0x82eB3a9403361b7AD0A662FB13eac1F82BA1e4FB; + + /** + * @notice Percentage of token sale price to be used for EIP-2981 royalties from secondary sales + * see https://eips.ethereum.org/EIPS/eip-2981 + * + * @dev Has 2 decimal precision. E.g. a value of 500 would result in a 5% royalty fee + * + */ + // **NOTE: royaltyFeesInBips value should be replace before mainnet deployment + uint96 public royaltyFeesInBips = 500; // 5% + + /** + * @dev Used if baseURIMode is false and token uri is blank + * + */ + // **NOTE: previewURI value should be change before mainnet deployment + string public previewURI = "https://arweave.net/OEpdYUZsk9vAteHHxvD-cwgl8BhM3m8Yp_E8f6TltLI/"; + + /** + * @dev Fired in `mint()` when token is minted + * + * @param _tokenId token id of minted token + * @param _tokenURI token URI of minted token + * @param _addressMinted address where the token is minted + */ + event NFTSingleMint(uint256 _tokenId, string _tokenURI, address _addressMinted); + + /** + * @dev Fired in `batchMint()` and `ownerMint()` when token are minted + * + * @param _tokenIds token id array of minted tokens + * @param _tokenURIs token uri array of minted tokens + * @param _addressMinted address where the token is minted + */ + event NFTBatchMint(uint256[] _tokenIds, string[] _tokenURIs, address _addressMinted); + + /** + * @dev Fired in `setWithdrawalRecipient()` when new withdrawal recipient is set + * + * @param _withdrawalRecipient new withdrawal address + */ + event WithdrawalRecipientChanged(address _withdrawalRecipient); + + /** + * @dev Fired in `setMaxMintPerAddress()` when new maxMintPerAddress is set + * + * @param _maxMintPerAddress new maxMintPerAddress + */ + event MaxMintPerAddressChanged(uint16 _maxMintPerAddress); + + /** + * @dev Fired in `setMintPrice()` when new mintPrice is set + * + * @param _mintPrice new mintPrice + */ + event MintPriceChanged(uint256 _mintPrice); + + /** + * @dev Fired in `setBaseTokenURI()` when new baseTOkenURI is set + * + * @param _baseTokenURI new baseTokenURI + */ + event BaseTokenURIChanged(string _baseTokenURI); + + /** + * @dev Fired in `setContractURI()` when new setContractURI is set + * + * @param _contractURI new contractURI + */ + event ContractURIChanged(string _contractURI); + + /** + * @dev Fired in `setMerkleRoot()` when new merkleRoot is set + * + * @param _merkleRoot new merkleRoot + */ + event MerkleRootChanged(bytes32 _merkleRoot); + + /** + * @dev Fired in `withdraw()` and `withdrawAll()` when contract balance is withdrawn + * + * @param _withdrawalAddress address of recipient should match the set witdrawalRecipient + * @param _amount amount withdrawn + */ + event Withdraw(address _withdrawalAddress, uint256 _amount); + + /** + * @dev Fired in `setRoyaltyInfo()` when new royaltyInfo is set + * + * @param _royaltyReceiverAddress address to receive EIP-2981 royalties from secondary sales + * @param royaltyFeesInBips has 2 decimal precision. E.g. a value of 500 would result in a 5% royalty fee + */ + event RoyaltyInfoChanged(address _royaltyReceiverAddress, uint96 royaltyFeesInBips); + + /** + * @dev Fired in `enableBaseURIMode()` and `disableBaseURIMode()` when new baseURIMode is set + * + * @param _baseURIMode new baseURIMode + */ + event BaseURIModeChanged(bool _baseURIMode); + + /** + * @dev Fired in `setPreviewURI()` when new previewURI is set + * + * @param _previewURI new previewURI + */ + event PreviewURIChanged(string _previewURI); + + + /** + * @param _name token name + * @param _symbol token symbol + * + */ constructor(string memory _name, string memory _symbol) ERC721(_name, _symbol) { + setRoyaltyInfo(royaltyReceiverAddress, royaltyFeesInBips); _pause(); } - modifier notCap() { - require(totalMinted < MAX_SUPPLY, "Max Reached"); + /** + * @dev Throws if totalMinted + total amount of nft to mint exceeds MAX_SUPPLY + * + * @param _nftQty quantity of nft to mint + */ + modifier isTotalMintedExceedsMaxSupply(uint256 _nftQty) { + require((totalMinted + _nftQty) <= MAX_SUPPLY, "Max Reached"); _; } - modifier isValidAmount() { - require(msg.value >= mintPrice, "Invalid Amount"); + /** + * @dev Throws if amount is not enough + * + * @param _nftQty quantity of nft to mint + */ + modifier isValidAmount(uint256 _nftQty) { + require(msg.value >= (mintPrice * _nftQty), "Invalid Amount"); _; } + /** + * @dev Throws if withdrawalRecipient is invalid + * + * @param _withdrawalRecipient withdrawalRecipient passed by caller + */ modifier isValidRecipient(address _withdrawalRecipient) { require( withdrawalRecipient == _withdrawalRecipient, @@ -47,21 +259,41 @@ contract Land is ERC721, ReentrancyGuard, Ownable, Pausable { _; } + /** + * @dev Throws if _amount exceeds contract balance + * + * @param _amount amount to withdraw + */ modifier isBalanceEnough(uint256 _amount) { require(_amount <= address(this).balance, "Not Enough Balance"); _; } + /** + * @dev Throws if contract balance is 0 + * + */ modifier isBalanceNotZero() { require(address(this).balance > 0, "No Balance"); _; } + /** + * @dev Throws if a token ID is already minted + * + * @param _tokenId token passed by caller + */ modifier isTokenExist(uint256 _tokenId) { require(_exists(_tokenId), "Token ID not exist"); _; } + + /** + * @dev Throws address is not on the whitelist + * + * @param _merkleProof address proof passed by caller + */ modifier isWhitelisted(bytes32[] memory _merkleProof) { require( isValidMerkleProof( @@ -73,42 +305,271 @@ contract Land is ERC721, ReentrancyGuard, Ownable, Pausable { _; } - function setPreviewURI(string memory _previewURI) external onlyOwner { + /** + * @dev Throws if caller balance + amount of nft to mint + * exceeds maxMintPerAddress + * + * @param _address address of minter + * @param _nftQty amount of nft to mint + */ + modifier isNFTBalanceExceedsMaxMintPerAddress(address _address, uint256 _nftQty) { + require( + (balanceOf(_address) + _nftQty) <= maxMintPerAddress, + "Max nft per address reached" + ); + _; + } + + /** + * @dev Throws if caller is a contract except if owner + * + */ + modifier isCallerValid() { + require( + tx.origin == msg.sender || owner() == msg.sender, + "Not allowed"); + _; + } + + /** + * @dev Throws if _tokenId exceeds MAX_SUPPLY + * + * @param _tokenId token id to check if valid passed by caller + */ + modifier isTokenIdValid(uint256 _tokenId) { + require(_tokenId <= MAX_SUPPLY, "Invalid token id"); + _; + } + + /** + * @dev Set the previewURI onlyOwner + * + * @param _previewURI new previewURI + */ + function setPreviewURI(string memory _previewURI) + external + onlyOwner + { previewURI = _previewURI; + + emit PreviewURIChanged(previewURI); } - function revealNFT( - string memory _baseTokenURI, - uint16 _lastTokenSoldAfterCutoff - ) external onlyOwner { - baseTokenURI = _baseTokenURI; - lastTokenSoldAfterCutoff = _lastTokenSoldAfterCutoff; + /** + * @dev Set the maxMintPerAddress onlyOwner + * + * @param _maxMintPerAddress new maxMintPerAddress + */ + function setMaxMintPerAddress(uint16 _maxMintPerAddress) + external + onlyOwner + { + maxMintPerAddress = _maxMintPerAddress; + + emit MaxMintPerAddressChanged(maxMintPerAddress); } - function mint(bytes32[] memory _merkleProof) - public + /** + * @dev Restricted mint function requires bytes32[] merkleProof + * + * @dev Unsafe: doesn't execute `onERC721Received` on the receiver. + * + * @dev Creates new token with token id specified + * and assigns an ownership to caller for this token + * + * @dev Store new tokenIdURI for this token + * + * @param _tokenId token id to mint + * @param _tokenURI token uri of token id to mint + * @param _merkleProof address proof for whitelisting + */ + function mint( + uint256 _tokenId, + string memory _tokenURI, + bytes32[] memory _merkleProof + ) + external + payable + whenNotPaused + isCallerValid + isTotalMintedExceedsMaxSupply(SINGLE_MINT_QTY) + isValidAmount(SINGLE_MINT_QTY) + isNFTBalanceExceedsMaxMintPerAddress(msg.sender, SINGLE_MINT_QTY) + isWhitelisted(_merkleProof) + { + _mintNFT(msg.sender, _tokenId, _tokenURI); + + emit NFTSingleMint(_tokenId, _tokenURI, msg.sender); + } + + /** + * @dev Restricted mint function requires bytes32[] merkleProof + * + * @dev Unsafe: doesn't execute `onERC721Received` on the receiver. + * + * @dev Creates new tokens with token ids specified + * and assigns an ownership to caller for this token + * + * @dev Store new tokenIdURI for this token + * + * @param _tokenIds token ids to mint + * @param _tokenURIs token uris of token id to mint + * @param _merkleProof address proof for whitelisting + */ + function batchMint( + uint256[] calldata _tokenIds, + string[] calldata _tokenURIs, + bytes32[] memory _merkleProof + ) + external payable whenNotPaused + isCallerValid + isTotalMintedExceedsMaxSupply(_tokenIds.length) + isValidAmount(_tokenIds.length) + isNFTBalanceExceedsMaxMintPerAddress(msg.sender, _tokenIds.length) isWhitelisted(_merkleProof) - notCap - isValidAmount { + _batchMintNFT(msg.sender, _tokenIds, _tokenURIs); + + emit NFTBatchMint(_tokenIds, _tokenURIs, msg.sender); + } + + /** + * @dev Restricted mint function requires onlyOwner + * + * @dev Unsafe: doesn't execute `onERC721Received` on the receiver. + * + * @dev Creates new tokens with token ids specified + * and assigns an ownership to `_addressToMint` for this token + * + * @dev Store new tokenIdURI for this token + * + * @param _addressToMint address to mint the token + * @param _tokenIds token ids to mint + * @param _tokenURIs token uris of token id to mint + */ + function ownerMint( + address _addressToMint, + uint256[] calldata _tokenIds, + string[] calldata _tokenURIs + ) + external + onlyOwner + isTotalMintedExceedsMaxSupply(_tokenIds.length) + isNFTBalanceExceedsMaxMintPerAddress(msg.sender, _tokenIds.length) + { + _batchMintNFT(_addressToMint, _tokenIds, _tokenURIs); + + emit NFTBatchMint(_tokenIds, _tokenURIs, msg.sender); + } + + /** + * @dev Private function for batchMint + * + * @param _address address to mint the token + * @param _tokenIds token ids to mint + * @param _tokenURIs token uris of token id to mint + */ + function _batchMintNFT( + address _address, + uint256[] calldata _tokenIds, + string[] calldata _tokenURIs + ) + private + { + for(uint8 i; i < _tokenIds.length; i++) { + uint256 tokenId = _tokenIds[i]; + _mintNFT(_address, tokenId, _tokenURIs[i]); + } + } + + /** + * @dev Private function for mint + * + * @param _address address to mint the token + * @param _tokenId token id to mint + * @param _tokenURI token uri of token id to mint + */ + function _mintNFT( + address _address, + uint256 _tokenId, + string memory _tokenURI + ) + private + isTokenIdValid(_tokenId) + { + // **NOTE: index will start at 1 totalMinted = totalMinted + 1; - _safeMint(msg.sender, totalMinted); + tokenIdURI[_tokenId] = _tokenURI; + _mint(_address, _tokenId); } - function setMintPrice(uint256 _mintPrice) public onlyOwner { + /** + * @dev Set new minting price + * + * @param _mintPrice new mint price + */ + function setMintPrice(uint256 _mintPrice) + external + onlyOwner + { mintPrice = _mintPrice; + + emit MintPriceChanged(mintPrice); } + /** + * @dev Set new withdrawal recipient + * + * @param _withdrawalRecipient new withdrawal recipient + */ function setWithdrawalRecipient(address _withdrawalRecipient) - public + external onlyOwner { withdrawalRecipient = _withdrawalRecipient; + + emit WithdrawalRecipientChanged(withdrawalRecipient); } + /** + * @dev Set new base token URI + * + * @param _baseTokenURI new base token URI + */ + function setBaseTokenURI(string memory _baseTokenURI) + external + onlyOwner + { + baseTokenURI = _baseTokenURI; + + emit BaseTokenURIChanged(baseTokenURI); + } + + /** + * @dev Set new contract URI + * + * @param _contractURI new contract URI + */ + function setContractURI(string memory _contractURI) + external + onlyOwner + { + contractURI = _contractURI; + + emit ContractURIChanged(contractURI); + } + + /** + * @inheritdoc ERC721 + * + * @dev if baseURIMode is false, returns stored uri in tokenIdURI + * if tokenIdURI is empty, returns previewURI + * + * @param _tokenId use to determin which token id uri to return + */ function tokenURI(uint256 _tokenId) public view @@ -116,14 +577,12 @@ contract Land is ERC721, ReentrancyGuard, Ownable, Pausable { isTokenExist(_tokenId) returns (string memory) { - if (_tokenId <= lastTokenSoldAfterCutoff) { - string memory baseURI = baseTokenURI; - + if (baseURIMode == true) { return - bytes(baseURI).length > 0 + bytes(baseTokenURI).length > 0 ? string( abi.encodePacked( - baseURI, + baseTokenURI, Strings.toString(_tokenId), tokenURISuffix ) @@ -131,23 +590,84 @@ contract Land is ERC721, ReentrancyGuard, Ownable, Pausable { : ""; } - return previewURI; + return + bytes(tokenIdURI[_tokenId]).length > 0 + ? tokenIdURI[_tokenId] + : previewURI; } - function setMerkleRoot(bytes32 _merkleRoot) public onlyOwner { + /** + * @dev Set new merkle root + * + * @param _merkleRoot new merkle root + */ + function setMerkleRoot(bytes32 _merkleRoot) + external + onlyOwner + { merkleRoot = _merkleRoot; + + emit MerkleRootChanged(merkleRoot); } - function pause() external onlyOwner { + /** + * @dev Set paused to true, prevent mint, and batchMint to be called + * + */ + function pause() + external + onlyOwner + { _pause(); } - function unpause() external onlyOwner { + /** + * @dev Set paused to false, allow mint, and batchMint to be called + * + */ + function unpause() + external + onlyOwner + { _unpause(); } + /** + * @dev enable tokenURI to return baseURI instead of stored + * tokenIdURI + * + */ + function enableBaseURIMode() + external + onlyOwner + { + baseURIMode = true; + + emit BaseURIModeChanged(baseURIMode); + } + + /** + * @dev disable tokenURI to return baseURI + * + */ + function disableBaseURIMode() + external + onlyOwner + { + baseURIMode = false; + + emit BaseURIModeChanged(baseURIMode); + } + + /** + * @dev send contract balance to withdrawal recipient requires onlyOwner + * and a valid withdrawalRecipient + * + * @param _withdrawalRecipient withdrawal recipient passed by caller + * @param _amount amount to withdraw + */ function withdraw(address _withdrawalRecipient, uint256 _amount) - public + external onlyOwner nonReentrant isValidRecipient(_withdrawalRecipient) @@ -156,8 +676,14 @@ contract Land is ERC721, ReentrancyGuard, Ownable, Pausable { _withdraw(_withdrawalRecipient, _amount); } + /** + * @dev send all contract balance to withdrawal recipient requires onlyOwner + * and a valid withdrawalRecipient + * + * @param _withdrawalRecipient withdrawal recipient passed by caller + */ function withdrawAll(address _withdrawalRecipient) - public + external onlyOwner isValidRecipient(_withdrawalRecipient) isBalanceNotZero @@ -165,25 +691,111 @@ contract Land is ERC721, ReentrancyGuard, Ownable, Pausable { uint256 balance = address(this).balance; _withdraw(_withdrawalRecipient, balance); + } - function _withdraw(address _withdrawalRecipient, uint256 _amount) internal { + /** + * @dev Private function called being called withdraw and withdrawAll + * + * @param _withdrawalRecipient withdrawal recipient passed by caller + * @param _amount amount to withdraw + */ + function _withdraw(address _withdrawalRecipient, uint256 _amount) + private + { (bool success, ) = (_withdrawalRecipient).call{value: _amount}(""); require(success, "Withdraw Failed"); + + emit Withdraw(_withdrawalRecipient, _amount); } - function getContractBalance() external view onlyOwner returns (uint256) { + /** + * @dev Returns contract balance + * + */ + function getContractBalance() + external + view + onlyOwner + returns (uint256) + { return address(this).balance; } - function getMerkleRoot() external view onlyOwner returns (bytes32) { + /** + * @dev Returns current merkleRoot + * + */ + function getMerkleRoot() + external + view + onlyOwner + returns (bytes32) + { return merkleRoot; } + /** + * @dev Returns current maxMintPerAddress + * + */ + function getMaxMintPerAddress() + external + view + onlyOwner + returns (uint16) + { + return maxMintPerAddress; + } + + /** + * @dev Validate merkleProof + * + * @param _merkleProof address proof + * @param _merkleLeaf address to validate the proof + */ function isValidMerkleProof( bytes32[] memory _merkleProof, bytes32 _merkleLeaf - ) private view returns (bool) { + ) + private + view + returns (bool) + { return MerkleProof.verify(_merkleProof, merkleRoot, _merkleLeaf); } + + /** + * @inheritdoc ERC2981 + * + */ + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721, ERC2981) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + /** + * @dev Set new royalty info + * + * @param _royaltyReceiverAddress address to receive royalty fee + * @param _royaltyFeesInBips Percentage of token sale price to be used for + * EIP-2981 royalties from secondary sales + * Has 2 decimal precision. E.g. a value of 500 would result in a 5% royalty fee + * value should be replace before mainnet deployment + */ + function setRoyaltyInfo( + address _royaltyReceiverAddress, + uint96 _royaltyFeesInBips + ) + public + onlyOwner + { + _setDefaultRoyalty(_royaltyReceiverAddress, _royaltyFeesInBips); + + emit RoyaltyInfoChanged(_royaltyReceiverAddress, _royaltyFeesInBips); + } } diff --git a/code/contracts/test/MockCallerContract.sol b/code/contracts/test/MockCallerContract.sol new file mode 100644 index 0000000..12eeb6f --- /dev/null +++ b/code/contracts/test/MockCallerContract.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.7; + +import "../Land.sol"; + +contract MockCallerContract { + Land landContract; + + constructor(Land _landContract) { + landContract = Land(_landContract); + } + + function contractCallMint(uint256 _tokenId, string memory _tokenURI, bytes32[] memory _merkleProof) external { + landContract.mint{value: 0.01 ether}(_tokenId,_tokenURI,_merkleProof); + } + + function contractCallBatchMint(uint256[] memory _tokenIds, string[] memory _tokenURIs, bytes32[] memory _merkleProof) external { + landContract.batchMint{value: (0.01 ether * _tokenIds.length)}(_tokenIds, _tokenURIs, _merkleProof); + } + + function contractCallOwnerMint(address _addressToMint, uint256[] memory _tokenIds, string[] memory _tokenURIs) external { + landContract.ownerMint(_addressToMint, _tokenIds, _tokenURIs); + } + + function contractCallSetMerkleRoot(bytes32 _merkleRoot) external { + landContract.setMerkleRoot(_merkleRoot); + } + + receive() external payable {} +} diff --git a/code/test/land.test.js b/code/test/land.test.js index 893f1c5..e07b5e1 100644 --- a/code/test/land.test.js +++ b/code/test/land.test.js @@ -1,17 +1,21 @@ const { expect } = require("chai"); const { ethers, waffle } = require("hardhat"); -const { generateMerkleRoot, generateProof } = require("../utils/merkletree"); +const { generateMerkle, generateProof } = require("../utils/merkletree"); +const { getShuffledUniqueNumbersByRange } = require("../utils/random-token-id"); +const { EVENTS } = require("../utils/const"); const NAME = "Land"; const SYMBOL = "LND"; -const INITIAL_PREVIEW_URI = "https://arweave.net/OEpdYUZsk9vAteHHxvD-cwgl8BhM3m8Yp_E8f6TltLI/"; -const TOKEN_URI_SUFFIX = ".json"; +const INITIAL_TOKEN_URI_SUFFIX = ".json"; const INITIAL_MINT_PRICE = ethers.utils.parseEther(".001"); const INITIAL_WITHDRAWAL_RECIPIENT_ADDRESS = "0x82eB3a9403361b7AD0A662FB13eac1F82BA1e4FB"; +const INITIAL_ROYALTY_FEE = 500; // 5% +const INITIAL_MINT_PER_WALLET = 5; let contract; let signer, withdrawalRecipient, otherAcc, notWhiteListedAcc; let merkleRoot; +let merkleTree; let signerMerkleProof; const {provider} = waffle; @@ -19,8 +23,11 @@ describe("Land", function() { before(async function() { [signer, withdrawalRecipient, otherAcc, notWhiteListedAcc] = await ethers.getSigners(); - merkleRoot = generateMerkleRoot([signer.address, otherAcc.address]); - signerMerkleProof = generateProof(signer.address); + const merkle = generateMerkle([signer.address, otherAcc.address]); + merkleRoot = merkle.root; + merkleTree = merkle.tree; + + signerMerkleProof = generateProof(merkleTree, signer.address); }) beforeEach(async function() { @@ -65,22 +72,21 @@ describe("Land", function() { expect(baseTokenURI).to.equal(""); }); - it("should return the preview uri", async function() { - const previewURI = await contract.previewURI(); - - expect(previewURI).to.equal(INITIAL_PREVIEW_URI); - }) - it("should return the uri suffix", async function() { const tokenURISuffix = await contract.tokenURISuffix(); - expect(tokenURISuffix).to.equal(TOKEN_URI_SUFFIX); + expect(tokenURISuffix).to.equal(INITIAL_TOKEN_URI_SUFFIX); }); it("should return merkle root", async function() { expect(await contract.getMerkleRoot()).to.equal(merkleRoot); }); + + it("should return max mint per wallet address", async function() { + + expect(await contract.getMaxMintPerAddress()).to.equal(INITIAL_MINT_PER_WALLET); + }); }); describe("setMintingPrice", function() { @@ -99,18 +105,35 @@ describe("Land", function() { expect(mintPrice).to.equal(newMintPrice); }); + + it("should emit event MintPriceChanged", async function() { + const price = "2"; + const newMintPrice = ethers.utils.parseEther(price); + + await expect(contract.setMintPrice(newMintPrice)).to.emit(contract, EVENTS.MINT_PRICE_CHANGED); + }); }); describe("setMerkleRoot", function() { it("should be able to set new merkle root", async function() { const signers = await ethers.getSigners(); const addresses = signers.map(signer => signer.address); - const newMerkleRoot = generateMerkleRoot(addresses); + const newMerkle = generateMerkle(addresses); + const newMerkleRoot = newMerkle.root; await contract.setMerkleRoot(newMerkleRoot); expect(await contract.getMerkleRoot()).to.equal(newMerkleRoot); - }) + }); + + it("should emit event MerkleRootChanged", async function() { + const signers = await ethers.getSigners(); + const addresses = signers.map(signer => signer.address); + const newMerkle = generateMerkle(addresses); + const newMerkleRoot = newMerkle.root; + + await expect(contract.setMerkleRoot(newMerkleRoot)).to.emit(contract, EVENTS.MERKLE_ROOT_CHANGED); + }); }) describe("setWithdrawalRecipient", function() { @@ -119,65 +142,59 @@ describe("Land", function() { await contract.setWithdrawalRecipient(newRecipientAddress); expect(await contract.withdrawalRecipient()).to.equal(newRecipientAddress); - }) - }); - - describe("setPreviewURI", function() { - it("should be able to set new previewURI", async function() { - const newPreviewURI = "ipfs://previewURI/"; - - await contract.setPreviewURI(newPreviewURI); + }); - expect(await contract.previewURI()).to.equal(newPreviewURI); + it("should emit event WithdrawalRecipientChanged", async function() { + const newRecipientAddress = otherAcc.address; + + await expect(await contract.setWithdrawalRecipient(newRecipientAddress)).to.emit(contract, EVENTS.WITHDRAWAL_RECIPIENT_CHANGED) }); }); - describe("reveal nft", function() { - const newBaseTokenURI = "https://arweave.net/newBaseTokenURI/"; - + describe("setMaxMintPerAddress", function() { beforeEach(async function() { await contract.unpause(); - const amountToMint = 5; - - for (let i = 0;i < amountToMint;i++) { - await contract.mint(signerMerkleProof, { - value: INITIAL_MINT_PRICE - }) - } }); - it("should be able to reveal nft", async function() { - const lastTokenSoldAfterCutoff = 5; - await contract.revealNFT(newBaseTokenURI, lastTokenSoldAfterCutoff); + it("sould be able to set new max mint per wallet address", async function() { + const newMaxMintPerAddress = 10; + await contract.setMaxMintPerAddress(newMaxMintPerAddress); + const otherAccProof = generateProof(merkleTree,otherAcc.address); + + for(let i=0;i < newMaxMintPerAddress; i++) { + const tokenId = i+1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; - const tokenId = 1; - const tokenURISuffix = await contract.tokenURISuffix(); - const expectedBaseTokenURI = `${newBaseTokenURI}${tokenId}${tokenURISuffix}`; + await contract.connect(otherAcc).mint(tokenId, tokenURI, otherAccProof, { + value: INITIAL_MINT_PRICE + }); + } - expect(await contract.tokenURI(tokenId)).to.equal(expectedBaseTokenURI); - expect(await contract.lastTokenSoldAfterCutoff()).to.equal(lastTokenSoldAfterCutoff); + expect(await contract.getMaxMintPerAddress()).to.equal(newMaxMintPerAddress); }); - it("should return token uri of unrevealed nfts", async function() { - const lastTokenSoldAfterCutoff = 2; - await contract.revealNFT(newBaseTokenURI, lastTokenSoldAfterCutoff); - const tokenId = 3; + it("sould emit event MaxMintPerAddressChanged", async function() { + const newMaxMintPerAddress = 10; - expect(await contract.tokenURI(tokenId)).to.equal(INITIAL_PREVIEW_URI); + await expect(contract.setMaxMintPerAddress(newMaxMintPerAddress)).to.emit(contract, EVENTS.MAX_MINT_PER_ADDRESS_CHANGED); }); + }); - it("should return token uri of unrevealed nfts", async function() { - const lastTokenSoldAfterCutoff = 2; - await contract.revealNFT(newBaseTokenURI, lastTokenSoldAfterCutoff); + describe("setBaseTokenURI", function() { + it("sould be able to set new base token uri", async function() { + const newBaseTokenURI = "https://arweave/"; + await contract.setBaseTokenURI(newBaseTokenURI); - const tokenId = 1; - const tokenURISuffix = await contract.tokenURISuffix(); - const expectedBaseTokenURI = `${newBaseTokenURI}${tokenId}${tokenURISuffix}`; + expect(await contract.baseTokenURI()).to.equal(newBaseTokenURI); + }); - expect(await contract.tokenURI(tokenId)).to.equal(expectedBaseTokenURI); + it("sould emit event BaseTokenURIChanged", async function() { + const newBaseTokenURI = "https://arweave/"; + + await expect(await contract.setBaseTokenURI(newBaseTokenURI)).to.emit(contract, EVENTS.BASE_TOKEN_URI_CHANGED) }); - }) + }); describe("getContractBalance", function() { const amountToMint = 5; @@ -185,7 +202,10 @@ describe("Land", function() { beforeEach(async function() { await contract.unpause(); for(let i=0;i < amountToMint; i++) { - await contract.mint(signerMerkleProof, { + const tokenId = i+1; + const tokenURI = `https://tokenuri.com/${tokenId}.json` + + await contract.mint(tokenId,tokenURI,signerMerkleProof, { value: INITIAL_MINT_PRICE }); } @@ -198,7 +218,7 @@ describe("Land", function() { expect(contractBalance).to.equal(ethers.utils.parseEther(String(expectedContractBalance))); }) - }) + }); describe("pausing", function() { it("should return if contract is paused", async function() { @@ -221,75 +241,526 @@ describe("Land", function() { }); }); - describe("minting", async function() { + describe("enable disable base uri mode", function() { + beforeEach(async function() { + await contract.unpause(); + }) + + it("should be able to enable base uri mode", async function() { + await contract.enableBaseURIMode(); + + expect(await contract.baseURIMode()).to.true; + }); + + it("should be able to disable base uri mode", async function() { + await contract.enableBaseURIMode(); + await contract.disableBaseURIMode(); + + expect(await contract.baseURIMode()).to.false; + }); + + it("should emit event BaseURIModeChanged", async function() { + await expect(contract.enableBaseURIMode()).to.emit(contract, EVENTS.BASE_URI_MODE_CHANGED); + }); + + it("should return base uri if base uri mode is true", async function() { + const newBaseURI = "https://arweave.net/"; + const tokenId = 1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await contract.setBaseTokenURI(newBaseURI); + await contract.mint(tokenId, tokenURI, signerMerkleProof, { + value: INITIAL_MINT_PRICE + }); + + const tokenURISuffix = await contract.tokenURISuffix(); + const expectedTokenURI = `${newBaseURI}${tokenId}${tokenURISuffix}` + + await contract.enableBaseURIMode(); + + expect(await contract.tokenURI(tokenId)).to.equal(expectedTokenURI); + }); + + it("should return token specific uri if base uri mode is false", async function() { + const amountToMint = 5; + + for(let i=0;i < amountToMint; i++) { + const tokenId = i+1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await contract.mint(tokenId, tokenURI, signerMerkleProof, { + value: INITIAL_MINT_PRICE + }); + + expect(await contract.tokenURI(tokenId)).to.equal(tokenURI); + } + }); + + it("should return preview uri if token uri is empty", async function() { + const previewURI = await contract.previewURI(); + const tokenId = 1; + const tokenURI = ""; + + await contract.mint(tokenId, tokenURI, signerMerkleProof, { + value: INITIAL_MINT_PRICE + }); + + expect(await contract.tokenURI(tokenId)).to.equal(previewURI); + }); + }); + + describe("batch minting", async function() { + let maxSupply; + + beforeEach(async function() { + maxSupply = Number(await contract.MAX_SUPPLY()); + await contract.unpause(); + }); + + it("should be able to batch mint nft", async function() { + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 5); + const nftQty = tokenIdsToMint.length; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await contract.batchMint(tokenIdsToMint,tokenURIs,signerMerkleProof, { + value: INITIAL_MINT_PRICE.mul(nftQty) + }); + + expect(await contract.balanceOf(signer.address)).to.equal(tokenIdsToMint.length); + }); + + it("should emit event NFTBatchMint", async function() { + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 5); + const nftQty = tokenIdsToMint.length; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await expect(contract.batchMint(tokenIdsToMint,tokenURIs,signerMerkleProof, { + value: INITIAL_MINT_PRICE.mul(nftQty) + })).to.emit(contract, EVENTS.NFT_BATCH_MINT); + }); + + it("should be able to batch mint nft from using contract if owner", async function() { + const TestCaller = await ethers.getContractFactory("MockCallerContract"); + const testCaller = await TestCaller.deploy(contract.address); + await testCaller.deployed(); + + await contract.transferOwnership(testCaller.address); + + const amountToTransfer = ethers.utils.parseEther("5"); + const gasLimit = 50000; + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 5); + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + const expectedTestCallerBalanceAfterBatchMint = tokenIdsToMint.length; + + await signer.sendTransaction({ + to: testCaller.address, + value: amountToTransfer, + gasLimit: gasLimit, + }); + + const newMerkle = generateMerkle([signer.address, otherAcc.address, testCaller.address]); + const newMerkleRoot = newMerkle.root; + const newMerkleTree = newMerkle.tree; + + await testCaller.contractCallSetMerkleRoot(newMerkleRoot); + + const testCallerProof = generateProof(newMerkleTree, testCaller.address); + await testCaller.contractCallBatchMint(tokenIdsToMint, tokenURIs, testCallerProof); + + expect(await contract.balanceOf(testCaller.address)).to.equal(expectedTestCallerBalanceAfterBatchMint) + }); + + it("should not be able to batch mint if contract is paused, should be reverted with error message 'Pausable: paused'", async function() { + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 5); + const nftQty = tokenIdsToMint.length; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await contract.pause(); + + await expect(contract.batchMint(tokenIdsToMint,tokenURIs,signerMerkleProof, { + value: INITIAL_MINT_PRICE.mul(nftQty) + })).to.be.revertedWith("Pausable: paused"); + }); + + it("duplicate token id, should be reverted with error message 'ERC721: token already minted'", async function() { + const tokenIdsToMint = [1,2,3,4,1]; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + const expectedBalanceAfterRevert = 0; + + await expect(contract.batchMint(tokenIdsToMint, tokenURIs, signerMerkleProof, { + value: INITIAL_MINT_PRICE * tokenIdsToMint.length + })).to.be.revertedWith("ERC721: token already minted"); + + expect(await contract.balanceOf(signer.address)).to.equal(expectedBalanceAfterRevert); + }); + + it("token id should not exceed max supply, should be reverted with error message 'Invalid token id'", async function() { + const tokenIdsToMint = [...getShuffledUniqueNumbersByRange(maxSupply,4),maxSupply+1]; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + const expectedBalanceAfterRevert = 0; + + await expect(contract.batchMint(tokenIdsToMint, tokenURIs, signerMerkleProof, { + value: INITIAL_MINT_PRICE * tokenIdsToMint.length + })).to.be.revertedWith("Invalid token id"); + + expect(await contract.balanceOf(signer.address)).to.equal(expectedBalanceAfterRevert); + }); + + it("invalid mint price amount, should be reverted with error message 'Invalid Amount'", async function() { + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 5); + const nftQty = tokenIdsToMint.length; + const invalidAmount = nftQty - 1; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await expect(contract.batchMint(tokenIdsToMint,tokenURIs,signerMerkleProof, { + value: INITIAL_MINT_PRICE.mul(invalidAmount) + })).to.be.revertedWith("Invalid Amount") + }); + + it("maxed supply, should be reverted with error message 'Max Reached'", async function() { + const newMaxMintPerAddress = 20; + await contract.setMaxMintPerAddress(newMaxMintPerAddress); + + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 19); + const nftQty = tokenIdsToMint.length; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await contract.batchMint(tokenIdsToMint,tokenURIs,signerMerkleProof, { + value: INITIAL_MINT_PRICE.mul(nftQty) + }); + + const otherAccProof = generateProof(merkleTree,otherAcc.address); + const otherAccTokenIdsToMint = [30,100]; + const otherAccQty = otherAccTokenIdsToMint.length; + const otherAccTokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await expect(contract.connect(otherAcc).batchMint(otherAccTokenIdsToMint,otherAccTokenURIs,otherAccProof, { + value: INITIAL_MINT_PRICE.mul(otherAccQty) + })).to.be.revertedWith("Max Reached"); + }); + + it("max mint per address, should be reverted with error message 'Max nft per address reached'", async function() { + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 6); + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + const expectedBalanceAfterRevert = 0; + + contract.batchMint(tokenIdsToMint, tokenURIs, signerMerkleProof, { + value: INITIAL_MINT_PRICE * tokenIdsToMint.length + }) + + expect(await contract.balanceOf(signer.address)).to.equal(expectedBalanceAfterRevert); + }); + + it("not whitelisted, should be reverted with error message 'Not on whitelist'", async function() { + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 5); + const nftQty = tokenIdsToMint.length; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + const falseMerkleProof = generateProof(merkleTree,notWhiteListedAcc.address); + + await expect(contract.batchMint(tokenIdsToMint,tokenURIs,falseMerkleProof, { + value: INITIAL_MINT_PRICE.mul(nftQty) + })).to.be.revertedWith("Not on whitelist"); + }); + + it("should not allow contract to call batch mint, should be reverted with error message 'Not allowed'", async function() { + const TestCaller = await ethers.getContractFactory("MockCallerContract"); + const testCaller = await TestCaller.deploy(contract.address); + await testCaller.deployed(); + + const amountToTransfer = ethers.utils.parseEther("5"); + const gasLimit = 50000; + + const tokenIdsToMint = [6,7,8,9,10]; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await signer.sendTransaction({ + to: testCaller.address, + value: amountToTransfer, + gasLimit: gasLimit, + }); + + await expect(testCaller.contractCallBatchMint(tokenIdsToMint, tokenURIs, signerMerkleProof)).to.be.revertedWith("Not allowed"); + }); + }); + + describe("owner minting", async function() { + let maxSupply; + + beforeEach(async function() { + maxSupply = Number(await contract.MAX_SUPPLY()); + }); + + it("should be able to owner mint nft", async function() { + const addressNFTReceiver = otherAcc.address; + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 5); + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await contract.ownerMint(addressNFTReceiver, tokenIdsToMint, tokenURIs); + + expect(await contract.balanceOf(addressNFTReceiver)).to.equal(tokenIdsToMint.length); + }); + + it("should emit event NFTBatchMint", async function() { + const addressNFTReceiver = otherAcc.address; + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 5); + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await expect(contract.ownerMint(addressNFTReceiver, tokenIdsToMint, tokenURIs)).to.emit(contract, EVENTS.NFT_BATCH_MINT); + }); + + it("duplicate token id, should be reverted with error message 'ERC721: token already minted'", async function() { + const addressNFTReceiver = otherAcc.address; + const tokenIdsToMint = [1,2,3,4,1]; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + const expectedBalanceAfterRevert = 0; + + await expect(contract.ownerMint(addressNFTReceiver, tokenIdsToMint, tokenURIs)).to.be.revertedWith("ERC721: token already minted"); + + expect(await contract.balanceOf(addressNFTReceiver)).to.equal(expectedBalanceAfterRevert); + }); + + it("token id should not exceed max supply, should be reverted with error message 'Invalid token id'", async function() { + const addressNFTReceiver = otherAcc.address; + const tokenIdsToMint = [...getShuffledUniqueNumbersByRange(maxSupply,4),maxSupply+1]; + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + const expectedBalanceAfterRevert = 0; + + await expect(contract.ownerMint(addressNFTReceiver, tokenIdsToMint, tokenURIs)).to.be.revertedWith("Invalid token id"); + + expect(await contract.balanceOf(addressNFTReceiver)).to.equal(expectedBalanceAfterRevert); + }); + + it("maxed supply, should be reverted with error message 'Max Reached'", async function() { + const newMaxMintPerAddress = 20; + await contract.setMaxMintPerAddress(newMaxMintPerAddress); + + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 19); + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await contract.ownerMint(signer.address, tokenIdsToMint, tokenURIs); + + const otherAccTokenIdsToMint = [30,100]; + const otherAccTokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + + await expect(contract.ownerMint(otherAcc.address, otherAccTokenIdsToMint, otherAccTokenURIs)).to.be.revertedWith("Max Reached"); + }); + + it("max mint per address, should be reverted with error message 'Max nft per address reached'", async function() { + const addressNFTReceiver = otherAcc.address; + const tokenIdsToMint = getShuffledUniqueNumbersByRange(maxSupply, 6); + const tokenURIs = tokenIdsToMint.map(tokenId => `https://s3.land.region.com/metadata/abcd/${tokenId}.json`); + const expectedBalanceAfterRevert = 0; + + contract.ownerMint(addressNFTReceiver, tokenIdsToMint, tokenURIs) + + expect(await contract.balanceOf(addressNFTReceiver)).to.equal(expectedBalanceAfterRevert); + }); + }); + + describe("minting", function() { + let maxSupply; + beforeEach(async function() { + maxSupply = Number(await contract.MAX_SUPPLY()); await contract.unpause(); }); - it("should be able to mint token", async function() { + it("should be able to mint nft", async function() { const expectedBalanceAfterMint = 1; + const tokenId = 1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; - await contract.mint(signerMerkleProof, { + await contract.mint(tokenId, tokenURI, signerMerkleProof, { value: INITIAL_MINT_PRICE }); expect(await contract.balanceOf(signer.address)).to.equal(expectedBalanceAfterMint); }); - it("should be able to mint token with different accs", async function() { + it("should emit event NFTSingleMint", async function() { + const expectedBalanceAfterMint = 1; + const tokenId = 1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await expect(contract.mint(tokenId, tokenURI, signerMerkleProof, { + value: INITIAL_MINT_PRICE + })).to.emit(contract, EVENTS.NFT_SINGLE_MINT); + + }); + + it("should be able to mint nft using contract if owner", async function() { + const TestCaller = await ethers.getContractFactory("MockCallerContract"); + const testCaller = await TestCaller.deploy(contract.address); + await testCaller.deployed(); + + await contract.transferOwnership(testCaller.address); + + const amountToTransfer = ethers.utils.parseEther("5"); + const gasLimit = 50000; + const tokenId = 8; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + const expectedTestCallerBalanceAfterMint = 1; + + await signer.sendTransaction({ + to: testCaller.address, + value: amountToTransfer, + gasLimit: gasLimit, + }); + + const newMerkle = generateMerkle([signer.address, otherAcc.address, testCaller.address]); + const newMerkleRoot = newMerkle.root; + const newMerkleTree = newMerkle.tree; + + await testCaller.contractCallSetMerkleRoot(newMerkleRoot); + + const testCallerProof = generateProof(newMerkleTree, testCaller.address); + await testCaller.contractCallMint(tokenId, tokenURI, testCallerProof); + + expect(await contract.balanceOf(testCaller.address)).to.equal(expectedTestCallerBalanceAfterMint); + }); + + it("should be able to mint nft with different accs", async function() { const testWallets = await ethers.getSigners(); const testWalletAddresses = testWallets.map(testWallet => testWallet.address); - const newMerkleRoot = generateMerkleRoot(testWalletAddresses); + const newMerkle = generateMerkle(testWalletAddresses); + const newMerkleRoot = newMerkle.root; + const newMerkleTree = newMerkle.tree; await contract.setMerkleRoot(newMerkleRoot); for(let i=0;i < testWallets.length; i++) { - const merkleProof = generateProof(testWallets[i].address); + const merkleProof = generateProof(newMerkleTree,testWallets[i].address); + const tokenId = i+1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; - expect(await contract.connect(testWallets[i]).mint(merkleProof, { + expect(await contract.connect(testWallets[i]).mint(tokenId, tokenURI, merkleProof, { value: INITIAL_MINT_PRICE })); } }); + it("token id should not exceed max supply, should be reverted with error message 'Invalid token id'", async function() { + const tokenId = maxSupply + 1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + const expectedBalanceAfterRevert = 0; + + await expect(contract.mint(tokenId, tokenURI, signerMerkleProof, { + value: INITIAL_MINT_PRICE + })).to.be.revertedWith("Invalid token id"); + + expect(await contract.balanceOf(signer.address)).to.equal(expectedBalanceAfterRevert); + }); + + it("token already minted, should be reverted with error message 'ERC721: token already minted'", async function() { + const tokenId = 1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await contract.mint(tokenId, tokenURI, signerMerkleProof, { + value: INITIAL_MINT_PRICE + }) + + await expect(contract.mint(tokenId, tokenURI, signerMerkleProof, { + value: INITIAL_MINT_PRICE + })).to.be.revertedWith("ERC721: token already minted"); + }); + it("should not be able to mint if contract is paused, should be reverted with error message 'Pausable: paused'", async function() { await contract.pause(); - await expect(contract.mint(signerMerkleProof, { + const tokenId = 1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await expect(contract.mint(tokenId, tokenURI, signerMerkleProof, { value: INITIAL_MINT_PRICE })).to.be.revertedWith("Pausable: paused"); }); it("maxed supply, should be reverted with error message 'Max Reached'", async function() { - const maxSupply = await contract.MAX_SUPPLY(); + const testWallets = (await ethers.getSigners()).slice(0, 6); + const testWalletAddresses = testWallets.map(testWallet => testWallet.address); + const newMerkle = generateMerkle(testWalletAddresses); + const newMerkleRoot = newMerkle.root; + const newMerkleTree = newMerkle.tree; - for (let i=0;i < maxSupply; i++) { - await contract.mint(signerMerkleProof, { + await contract.setMerkleRoot(newMerkleRoot); + + for(let i=0;i < testWallets.length; i++) { + const tokenId = i+1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + const merkleProof = generateProof(newMerkleTree,testWallets[i].address); + + await contract.connect(testWallets[i]).mint(tokenId, tokenURI, merkleProof, { value: INITIAL_MINT_PRICE - }) - } + }); - await expect(contract.mint(signerMerkleProof, { - value: INITIAL_MINT_PRICE - })).to.be.revertedWith("Max Reached"); + if (i === testWallets.length) { + await expect(contract.connect(testWallets[i]).mint(tokenId, tokenURI, merkleProof, { + value: INITIAL_MINT_PRICE + })).to.be.revertedWith("Max Reached"); + } + } }); it("invalid mint price amount, should be reverted with error message 'Invalid Amount'", async function() { const amount = ".0001"; const invalidMintAmount = ethers.utils.parseEther(amount); + const tokenId = 1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; - await expect(contract.mint(signerMerkleProof, { + await expect(contract.mint(tokenId, tokenURI, signerMerkleProof, { value: invalidMintAmount })).to.be.revertedWith("Invalid Amount"); }); it("not whitelisted, should be reverted with error message 'Not on whitelist'", async function() { - const falseMerkleProof = generateProof(notWhiteListedAcc.address); + const falseMerkleProof = generateProof(merkleTree,notWhiteListedAcc.address); + const tokenId = 1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; - await expect(contract.mint(falseMerkleProof, { + await expect(contract.mint(tokenId, tokenURI, falseMerkleProof, { value: INITIAL_MINT_PRICE })).to.be.revertedWith("Not on whitelist"); }); + + it("max mint per address, should be reverted with error message 'Max nft per address reached'", async function() { + const amountToMint = await contract.getMaxMintPerAddress(); + const otherAccProof = generateProof(merkleTree,otherAcc.address); + + for(let i=0;i < amountToMint; i++) { + const tokenId = i+1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await contract.connect(otherAcc).mint(tokenId, tokenURI, otherAccProof, { + value: INITIAL_MINT_PRICE + }); + + if(i === amountToMint - 1) { + await expect(contract.connect(otherAcc).mint(tokenId, tokenURI, otherAccProof, { + value: INITIAL_MINT_PRICE + })).to.be.revertedWith("Max nft per address reached"); + } + } + }); + + it("should not allow contract to call mint, should be reverted with error message 'Not allowed'", async function() { + const TestCaller = await ethers.getContractFactory("MockCallerContract"); + const testCaller = await TestCaller.deploy(contract.address); + await testCaller.deployed(); + + const amountToTransfer = ethers.utils.parseEther("5"); + const gasLimit = 50000; + + const tokenId = 1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await signer.sendTransaction({ + to: testCaller.address, + value: amountToTransfer, + gasLimit: gasLimit, + }); + + await expect(testCaller.contractCallMint(tokenId, tokenURI, signerMerkleProof)).to.be.revertedWith("Not allowed"); + }); }); describe("withdrawAll", function() { @@ -303,7 +774,10 @@ describe("Land", function() { await contract.unpause(); for(let i=0;i < amountToMint; i++) { - await contract.mint(signerMerkleProof, { + const tokenId = i+1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await contract.mint(tokenId, tokenURI, signerMerkleProof, { value: INITIAL_MINT_PRICE }); } @@ -320,12 +794,31 @@ describe("Land", function() { expect(balanceOfRecipientAfterWitdhrawInWei).to.equal(expectedBalanceOfRecipientInWei); expect(balanceOfContractAfterWithdraw).to.equal(0); }); + + it("should emit event Withdraw", async function() { + const amountToMint = 5; + + await contract.unpause(); + for(let i=0;i < amountToMint; i++) { + const tokenId = i+1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await contract.mint(tokenId, tokenURI, signerMerkleProof, { + value: INITIAL_MINT_PRICE + }); + } + + await expect(contract.withdrawAll(withdrawalRecipient.address)).to.emit(contract, EVENTS.WITHDRAW); + }); + it("invalid recipient, should be reverted with error message 'Invalid Recipient'", async function() { await expect(contract.withdrawAll(otherAcc.address)).to.be.revertedWith("Invalid Recipient"); }); + it("invalid balance, should be reverted with error message 'No Balance'", async function() { await expect(contract.withdrawAll(withdrawalRecipient.address)).to.be.revertedWith("No Balance"); }); + it("caller is not owner, should be reverted with errror message 'Ownable: caller is not the owner'", async function() { await expect(contract.connect(otherAcc).withdrawAll(withdrawalRecipient.address)).to.be.revertedWith("Ownable: caller is not the owner"); }); @@ -341,7 +834,10 @@ describe("Land", function() { beforeEach(async function() { await contract.unpause(); for(let i = 0;i < amountToMint; i++) { - await contract.mint(signerMerkleProof, { + const tokenId = i+1; + const tokenURI = `https://s3.land.region.com/metadata/abcd/${tokenId}.json`; + + await contract.mint(tokenId, tokenURI, signerMerkleProof, { value: INITIAL_MINT_PRICE }) } @@ -369,6 +865,22 @@ describe("Land", function() { expect(contractBalanceAfterWithdrawInWei).to.not.equal(ethers.utils.parseEther("0")); expect(contractBalanceAfterWithdrawInWei).to.equal(expectedContractBalanceAfterWithdrawInWei); }); + + it("should be able to withdraw balance", async function() { + const contractBalanceBeforeWithdrawInWei = await contract.getContractBalance(); + let amountWitdrawnInWei = ethers.utils.parseEther("0"); + + for(let i = 0; i < 4; i++) { + const currentContractBalanceInWei = await contract.getContractBalance(); + const randomAmountInWei = Math.floor(Math.random() * currentContractBalanceInWei); + const amountToWitdrawInEther = ethers.utils.formatEther(String(randomAmountInWei)); + + amountWitdrawnInWei = amountWitdrawnInWei.add(ethers.utils.parseEther(amountToWitdrawInEther)); + + await expect(contract.withdraw(withdrawalRecipient.address, ethers.utils.parseEther(amountToWitdrawInEther))).to.emit(contract, EVENTS.WITHDRAW); + } + }); + it("should be able to withdraw all balance", async function() { let amountWitdrawnInWei = ethers.utils.parseEther("0"); @@ -383,18 +895,83 @@ describe("Land", function() { expect(contractBalanceAfterWithdrawInWei).to.equal(ethers.utils.parseEther("0")); }); + it("invalid recipient, should be reverted with error message 'Invalid Recipient'", async function() { const amountToWithdraw = ethers.utils.parseEther("1"); await expect(contract.withdraw(otherAcc.address, amountToWithdraw)).to.be.revertedWith("Invalid Recipient"); }); + it("invalid balance, should be reverted with error message 'Not Enough Balance'", async function() { const contractBalanceInWei = await contract.getContractBalance(); const amountToWithdrawInWei = contractBalanceInWei.add(ethers.utils.parseEther("1")); await expect(contract.withdraw(withdrawalRecipient.address, amountToWithdrawInWei)).to.be.revertedWith("Not Enough Balance"); }); + it("caller is not owner, should be reverted with errror message 'Ownable: caller is not the owner'", async function() { const amountToWithdrawInWei = ethers.utils.parseEther("1"); await expect(contract.connect(otherAcc).withdraw(withdrawalRecipient.address, amountToWithdrawInWei)).to.be.revertedWith("Ownable: caller is not the owner"); }); }) + + describe("royalty info", function() { + it("should return current royalty info", async function() { + const exampleTokenId = 1; + const exampleSalePrice = 10000; + + const [currentRoyaltyReceiverAddress, currentRoyaltyFee] = await contract.royaltyInfo(exampleTokenId, exampleSalePrice); + + expect(currentRoyaltyReceiverAddress).equal(INITIAL_WITHDRAWAL_RECIPIENT_ADDRESS); + expect(currentRoyaltyFee).equal(INITIAL_ROYALTY_FEE); + }); + + it("should be able to set new royalty info", async function() { + const newReceiver = otherAcc.address; + const newRoyalTyFee = 100; // 1% + const exampleTokenId = 1; + const exampleSalePrice = 10000; + + await contract.setRoyaltyInfo(newReceiver, newRoyalTyFee); + + const [receiverAddress,royaltyFee] = await contract.royaltyInfo(exampleTokenId, exampleSalePrice) + + expect(receiverAddress).to.equal(otherAcc.address); + expect(royaltyFee).to.equal(newRoyalTyFee); + }); + + it("should emit event RoyaltyInfoChanged", async function() { + const newReceiver = otherAcc.address; + const newRoyalTyFee = 100; // 1% + + await expect(contract.setRoyaltyInfo(newReceiver, newRoyalTyFee)).to.emit(contract, EVENTS.ROYALTY_INFO_CHANGED); + + }); + }); + + describe("setContractURI", function() { + const newContractURI = "https://contractURI/" + + it("should be able to set new contract URI", async function() { + await contract.setContractURI(newContractURI); + + expect(await contract.contractURI()).to.equal(newContractURI); + }); + + it("should emit event ContractURIChanged", async function() { + await expect(contract.setContractURI(newContractURI)).to.emit(contract, EVENTS.CONTRACT_URI_CHANGED); + }); + }); + + describe("setPreviewURI", function() { + const newPreviewURI = "https://arweave.net/previewURI/" + + it("should be able to set new preview URI", async function() { + await contract.setPreviewURI(newPreviewURI); + + expect(await contract.previewURI()).to.equal(newPreviewURI); + }); + + it("should emit event PreviewURIChanged", async function() { + await expect(contract.setPreviewURI(newPreviewURI)).to.emit(contract, EVENTS.PREVIEW_URI_CHANGED); + }); + }); }) \ No newline at end of file diff --git a/code/utils/const.js b/code/utils/const.js new file mode 100644 index 0000000..2476a59 --- /dev/null +++ b/code/utils/const.js @@ -0,0 +1,16 @@ +const EVENTS = { + MAX_MINT_PER_ADDRESS_CHANGED: "MaxMintPerAddressChanged", + MINT_PRICE_CHANGED: "MintPriceChanged", + NFT_SINGLE_MINT: "NFTSingleMint", + NFT_BATCH_MINT: "NFTBatchMint", + WITHDRAWAL_RECIPIENT_CHANGED: "WithdrawalRecipientChanged", + BASE_TOKEN_URI_CHANGED: "BaseTokenURIChanged", + CONTRACT_URI_CHANGED: "ContractURIChanged", + MERKLE_ROOT_CHANGED: "MerkleRootChanged", + WITHDRAW: "Withdraw", + ROYALTY_INFO_CHANGED: "RoyaltyInfoChanged", + BASE_URI_MODE_CHANGED: "BaseURIModeChanged", + PREVIEW_URI_CHANGED: "PreviewURIChanged" +} + +module.exports = { EVENTS }; \ No newline at end of file diff --git a/code/utils/merkletree.js b/code/utils/merkletree.js index 17d5600..16d31f0 100644 --- a/code/utils/merkletree.js +++ b/code/utils/merkletree.js @@ -1,17 +1,18 @@ const { MerkleTree } = require('merkletreejs'); const keccak256 = require("keccak256"); -let merkleTree; - -function generateMerkleRoot(addresses) { +function generateMerkle(addresses) { const merkleLeaves = addresses.map(address => keccak256(address)); - merkleTree = new MerkleTree(merkleLeaves, keccak256, { sortPairs: true }); + const merkleTree = new MerkleTree(merkleLeaves, keccak256, { sortPairs: true }); const merkleRoot = merkleTree.getRoot().toString("hex"); - return buf2hex(merkleRoot); + return { + root: buf2hex(merkleRoot), + tree: merkleTree + } } -function generateProof(address) { +function generateProof(merkleTree, address) { const leaf = keccak256(address); const bufProof = merkleTree.getProof(leaf); const proof = bufProof.map(buf => buf2hex(buf.data)); @@ -24,6 +25,6 @@ function buf2hex(buf) { } module.exports = { - generateMerkleRoot, + generateMerkle, generateProof } diff --git a/code/utils/random-token-id.js b/code/utils/random-token-id.js new file mode 100644 index 0000000..0683890 --- /dev/null +++ b/code/utils/random-token-id.js @@ -0,0 +1,10 @@ +function getShuffledUniqueNumbersByRange(range, amount) { + const numbers = Array(range).fill().map((num, index) => index + 1); + numbers.sort(() => Math.random() - 0.5); + + return numbers.slice(0, amount); +} + +module.exports = { + getShuffledUniqueNumbersByRange, +}; \ No newline at end of file diff --git a/config.json b/config.json index 57e623e..8417c03 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "client_name": "Casino Metaverse", - "commit_hash": "91140addb06bf9df3ad3ed38ea0509406c0eafb9", + "commit_hash": "bf607afe5ff68b91990649567347f333bb27f6be", "date": "August 2022", "date_interval": "August the 1st to August the 5th, 2022", "issues": [],