diff --git a/contracts/Foo721.sol b/contracts/Foo721.sol new file mode 100644 index 0000000..8cc4c4a --- /dev/null +++ b/contracts/Foo721.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.7; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/Counters.sol"; + +contract Foo721 is ERC721, ERC721Enumerable, Ownable { + using Counters for Counters.Counter; + + Counters.Counter private _tokenIdCounter; + + uint256 public constant MAX_SUPPLY = 100; + uint256 public constant FREE_MINT_COUNT = 5; + uint256 public constant MINT_PRICE = 0.001 ether; + // solhint-disable-next-line var-name-mixedcase + uint256 public immutable MINT_DATE; + + mapping(address => bool) public freeMinted; + + string public baseURI; + + error InsufficientFunds(); + error MaxSupplyExceeded(); + error AlreadyFreeMinted(); + error FreeMintExceeded(); + error TransferTxError(); + error InvalidDate(); + + event FreeMinted(address minter); + + //solhint-disable-next-line + constructor(uint256 mintDate) ERC721("Foo721", "F721") { + MINT_DATE = mintDate; + } + + function setBaseURI(string memory baseURI_) external onlyOwner { + baseURI = baseURI_; + } + + function _baseURI() internal view override returns (string memory) { + return baseURI; + } + + function mint(address to, uint256 quantity) external payable { + // solhint-disable-next-line not-rely-on-time + if (block.timestamp < MINT_DATE) revert InvalidDate(); + + if (totalSupply() + quantity > MAX_SUPPLY) revert MaxSupplyExceeded(); + + if (msg.value < MINT_PRICE * quantity) revert InsufficientFunds(); + + for (uint256 index = 0; index < quantity; ) { + _tokenIdCounter.increment(); + + _safeMint(to, _tokenIdCounter.current()); + + unchecked { + ++index; + } + } + } + + function freeMint(address to) external { + if (_tokenIdCounter.current() >= FREE_MINT_COUNT) + revert FreeMintExceeded(); + if (freeMinted[to]) revert AlreadyFreeMinted(); + + freeMinted[to] = true; + _tokenIdCounter.increment(); + + _safeMint(to, _tokenIdCounter.current()); + + emit FreeMinted(to); + } + + function withdraw() external { + // solhint-disable-next-line avoid-low-level-calls + (bool isSuccess, ) = payable(msg.sender).call{ + value: address(this).balance + }(""); + + if (!isSuccess) revert TransferTxError(); + } + + // The following functions are overrides required by Solidity. + function _beforeTokenTransfer( + address from, + address to, + uint256 tokenId + ) internal override(ERC721, ERC721Enumerable) { + ERC721Enumerable._beforeTokenTransfer(from, to, tokenId); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721, ERC721Enumerable) + returns (bool) + { + return ERC721Enumerable.supportsInterface(interfaceId); + } +} diff --git a/test/Foo721.test.ts b/test/Foo721.test.ts new file mode 100644 index 0000000..bdd25ea --- /dev/null +++ b/test/Foo721.test.ts @@ -0,0 +1,124 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { expect, assert } from 'chai'; +import { Contract, ContractFactory, constants } from 'ethers'; +import { ethers } from 'hardhat'; + +const name: string = 'Foo721'; + +describe(name, () => { + let contract: Contract; + let owner: SignerWithAddress; + let addresses: SignerWithAddress[]; + let factory: ContractFactory; + const MINT_PRICE = ethers.utils.parseEther('0.001'); + + // hooks + before(async () => { + [owner, ...addresses] = await ethers.getSigners(); + factory = await ethers.getContractFactory(name); + }); + + beforeEach(async () => { + const now = Math.floor(+new Date() / 1000); + contract = await factory.deploy(now + 750); + }); + + // mint tests + it('should MINT successfully', async () => { + await ethers.provider.send('evm_increaseTime', [1000]); + + await expect( + contract.mint(addresses[0].address, 1, { value: String(MINT_PRICE) }) + ) + .to.emit(contract, 'Transfer') + .withArgs(constants.AddressZero, addresses[0].address, 1); + + await ethers.provider.send('evm_increaseTime', [-1000]); + }); + + it('should not MINT if max supply exceeds', async () => { + await ethers.provider.send('evm_increaseTime', [1000]); + + await contract.mint(addresses[5].address, 100, { + value: String(MINT_PRICE.mul(100)), + }); + + await expect( + contract.mint(addresses[7].address, 1, { + value: String(MINT_PRICE.mul(1)), + }) + ).to.be.revertedWith('MaxSupplyExceeded'); + await ethers.provider.send('evm_increaseTime', [-1000]); + }); + + it('should not MINT if InsufficientFunds', async () => { + await ethers.provider.send('evm_increaseTime', [1000]); + + await expect(contract.mint(addresses[0].address, 1)).to.be.revertedWith( + 'InsufficientFunds' + ); + + await ethers.provider.send('evm_increaseTime', [-1000]); + }); + + it('should Free Mint', async () => { + await ethers.provider.send('evm_increaseTime', [1000]); + + await expect(contract.freeMint(addresses[0].address)) + .to.emit(contract, 'Transfer') + .withArgs(constants.AddressZero, addresses[0].address, 1); + + await ethers.provider.send('evm_increaseTime', [-1000]); + }); + + it('should Not Free Mint if already Minted', async () => { + await ethers.provider.send('evm_increaseTime', [1000]); + + await contract.freeMint(addresses[0].address); + + await expect(contract.freeMint(addresses[0].address)).to.be.revertedWith( + 'AlreadyFreeMinted' + ); + + await ethers.provider.send('evm_increaseTime', [-1000]); + }); + + it('should Not Free Mint if FreeMintExceeded', async () => { + await ethers.provider.send('evm_increaseTime', [1000]); + + for (let index = 0; index < 5; index++) { + await contract.freeMint(addresses[index].address); + } + + await expect(contract.freeMint(addresses[6].address)).to.be.revertedWith( + 'FreeMintExceeded' + ); + + await ethers.provider.send('evm_increaseTime', [-1000]); + }); + + // withdraw tests + it('should withdraw eth successfully', async () => { + await ethers.provider.send('evm_increaseTime', [1000]); + + await contract.mint(addresses[5].address, 1, { + value: String(MINT_PRICE.mul(1)), + }); + + const beforeBalance = await ethers.provider.getBalance(owner.address); + + const tx = await contract.withdraw(); + + const afterBalance = await ethers.provider.getBalance(owner.address); + + const receipt = await tx.wait(); + + const fee = receipt.effectiveGasPrice * receipt.gasUsed; + + assert.equal( + (await beforeBalance).add(MINT_PRICE).sub(fee).toString(), + afterBalance.toString() + ); + await ethers.provider.send('evm_increaseTime', [-1000]); + }); +});