├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml └── workflows │ ├── dapp-build.yaml │ └── tests.yaml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── minting-dapp ├── .gitignore ├── package.json ├── postcss.config.js ├── public │ └── index.html ├── src │ ├── images │ │ ├── fav.png │ │ ├── logo.png │ │ └── preview.png │ ├── scripts │ │ ├── lib │ │ │ ├── NftContractType.ts │ │ │ └── Whitelist.ts │ │ ├── main.tsx │ │ └── react │ │ │ ├── CollectionStatus.tsx │ │ │ ├── Dapp.tsx │ │ │ └── MintWidget.tsx │ └── styles │ │ ├── components │ │ ├── general.scss │ │ └── minting-dapp.scss │ │ └── main.scss ├── tailwind.config.js ├── tsconfig.json ├── webpack.config.js └── yarn.lock ├── package.json ├── smart-contract ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierignore ├── .solhint.json ├── .solhintignore ├── config │ ├── CollectionConfig.ts │ ├── ContractArguments.ts │ └── whitelist.json ├── contracts │ └── YourNftToken.sol ├── hardhat.config.ts ├── lib │ ├── CollectionConfigInterface.ts │ ├── MarketplaceConfigInterface.ts │ ├── Marketplaces.ts │ ├── NetworkConfigInterface.ts │ ├── Networks.ts │ └── NftContractProvider.ts ├── package.json ├── scripts │ ├── 1_deploy.ts │ ├── 2_whitelist_open.ts │ ├── 3_whitelist_close.ts │ ├── 4_presale_open.ts │ ├── 5_presale_close.ts │ ├── 6_public_sale_open.ts │ ├── 7_public_sale_close.ts │ └── 8_reveal.ts ├── test │ └── index.ts ├── tsconfig.json └── yarn.lock └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us spot and fix bugs 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 23 | 24 | ### General information 25 | | Description | Value | 26 | | --- | --- | 27 | | Project version | X.Y.Z | 28 | | Operating system and version | ??? | 29 | | NodeJS version | X.Y.Z | 30 | | NPM version | X.Y.Z | 31 | | Yarn version | X.Y.Z | 32 | | Truffle version | X.Y.Z | 33 | 34 | ### Describe the bug 35 | A clear and concise description of what the bug is. 36 | 37 | ### How to Reproduce 38 | Steps to reproduce the behavior (please provide a reproducer project for complex bugs): 39 | 1. Go to '...' 40 | 2. Click on '....' 41 | 3. Scroll down to '....' 42 | 4. See error 43 | 44 | ### Expected behavior 45 | A clear and concise description of what you expected to happen. 46 | 47 | ### Screenshots 48 | If applicable, add screenshots to help explain your problem. 49 | 50 | ### Additional context 51 | Add any other context about the problem here. 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Community Support 4 | url: https://github.com/hashlips-lab/nft-erc721-collection/discussions 5 | about: Please ask and answer questions here. 6 | - name: HashLips Discord Server 7 | url: https://discord.gg/qh6MWhMJDN 8 | about: Here you can ask live support and/or recruit developers (or other team members) for your projects. -------------------------------------------------------------------------------- /.github/workflows/dapp-build.yaml: -------------------------------------------------------------------------------- 1 | name: DAPP build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | dapp_build: 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup Node.js (Smart contract) 22 | uses: actions/setup-node@v2 23 | with: 24 | cache: yarn 25 | cache-dependency-path: smart-contract/yarn.lock 26 | 27 | - name: Yarn install (Smart contract) 28 | run: cd ./smart-contract && yarn install 29 | env: 30 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Compile smart contract 33 | run: cd ./smart-contract && yarn compile 34 | 35 | - name: Setup Node.js (DAPP) 36 | uses: actions/setup-node@v2 37 | with: 38 | cache: yarn 39 | cache-dependency-path: minting-dapp/yarn.lock 40 | 41 | - name: Yarn install (DAPP) 42 | run: cd ./minting-dapp && yarn install 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Build DAPP 47 | run: cd ./minting-dapp && yarn build 48 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Hardhat tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | cache: yarn 20 | cache-dependency-path: smart-contract/yarn.lock 21 | 22 | - name: Yarn install 23 | run: cd ./smart-contract && yarn install 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: Run tests 28 | run: cd ./smart-contract && yarn test-extended 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "solidity.packageDefaultDependenciesDirectory": "smart-contract/node_modules", 3 | "solidity.compileUsingRemoteVersion": "0.8.9", 4 | "editor.tabSize": 2 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Marco Lipparini 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NFT ERC721 Collection 2 | 3 | An all-in-one solution for `ERC721` collections. Build, test and deploy your smart contract, together with a totally 4 | integrated DAPP within a simple yet powerful workspace. 5 | 6 | ## Disclaimer 7 | This project was created for educational purposes, please refer to the [LICENCE](LICENSE) file for further information. 8 | 9 | ## Main features 10 | - extremely high gas efficiency (users are going to pay lower gas fees compared to traditional collections) 11 | - whitelist support with customizable list size (using a Merkle Tree for verification) 12 | - automated contract verification through block explorers (e.g. Etherscan) 13 | - simple CLI commands that guide you through all the sale steps (whitelist, pre-sale, public sale) 14 | - built as a Hardhat project with TypeScript support for a better development experience 15 | - includes a fully-featured minting DAPP (React + TypeScript + SCSS + Webpack) 16 | - full support for contract interaction through block explorers (e.g. Etherscan), for all the users that do not trust custom DAPPs (including the `whitelistMint(...)` function) 17 | - customizable minting DAPP (from basic branding to complete customization) 18 | - now based on `ERC721A` 19 | 20 | ## YouTube tutorials 21 | 22 | |Lesson ID|Description|Video link| 23 | |---|---|---| 24 | |`00a`|Basic setup on **Windows 10**|https://youtu.be/zjlg-0622PU| 25 | |`00b`|Basic setup on **macOS Catalina (Intel-based)**|https://youtu.be/acqXzKN5Xys| 26 | |`00c`|Basic setup on **Linux**|https://youtu.be/imuqi6Vg3Zw| 27 | |`01`|Speedrun: create and deploy a smart contract + DAPP (v2.x)|https://youtu.be/AFrsJRLrZEM| 28 | |`02`|The smart contract project|https://youtu.be/XToWWExBLXE| 29 | |`03`|The minting DAPP project|https://youtu.be/gs9mVwkn8u4| 30 | |`04`|Configuration and security|https://youtu.be/pkA86GHU_xw| 31 | |`05`|Managing the collection without leaving Visual Studio Code|https://youtu.be/yOVKEeRMJSs| 32 | |`06`|Managing the contract using Truffle Dashboard|https://youtu.be/fwdIA5UuPmM| 33 | |`07`|Running smart contract functions manually on the block explorer|https://youtu.be/zhvTJhBbtnE| 34 | |`08`|Customizing the look and feel of the DAPP|https://youtu.be/GoDp6yZAY9A| 35 | |`09`|Deploying the DAPP|https://youtu.be/uUrbIXUgVz4| 36 | 37 | ## Legacy tutorials 38 | 39 | |Description|Video link| 40 | |---|---| 41 | |Speedrun: create and deploy a smart contract + DAPP (v1.x)|https://youtu.be/VpXJZSqLO8A| 42 | 43 | ## Requirements 44 | 45 | ### Software 46 | - [Visual Studio Code](https://code.visualstudio.com/) (with the [Solidity](https://marketplace.visualstudio.com/items?itemName=JuanBlanco.solidity) extension) 47 | - [NodeJs](https://nodejs.org/) (with the [Yarn package manager](https://yarnpkg.com/getting-started/install)) 48 | 49 | ### Services 50 | - Etherscan free API key _(optional: used for the automated contract verificiation, as well as retrieving the current values for gas cost estimation)_ 51 | - Infura free basic plan or higher _(optional: used by the CLI commands in order to perform operations on real blockchains, you can skip this if you deploy and manage your contract [using Truffle Dashboard](https://youtu.be/fwdIA5UuPmM))_ 52 | - Coin Market Cap free API key _(optional: used for retrieving the current token price for gas cost estimation in USD)_ 53 | -------------------------------------------------------------------------------- /minting-dapp/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/build -------------------------------------------------------------------------------- /minting-dapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hashlips-lab/nft-erc721-collection-minting-dapp", 3 | "version": "0.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "@babel/preset-react": "^7.0.0", 7 | "@metamask/detect-provider": "^1.2.0", 8 | "@symfony/webpack-encore": "^1.7.0", 9 | "@types/react": "^17.0.37", 10 | "@types/react-dom": "^17.0.11", 11 | "@walletconnect/web3-provider": "^1.7.1", 12 | "assert": "^2.0.0", 13 | "autoprefixer": "^10.4.0", 14 | "buffer": "^6.0.3", 15 | "core-js": "^3.0.0", 16 | "ethers": "^5.5.3", 17 | "file-loader": "^6.0.0", 18 | "hardhat-typechain": "^0.3.5", 19 | "keccak256": "^1.0.6", 20 | "merkletreejs": "^0.2.27", 21 | "node-polyfill-webpack-plugin": "^1.1.4", 22 | "postcss-loader": "^6.2.1", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2", 25 | "react-toastify": "^9.0.3", 26 | "regenerator-runtime": "^0.13.2", 27 | "sass": "^1.45.0", 28 | "sass-loader": "^12.4.0", 29 | "stream": "^0.0.2", 30 | "tailwindcss": "^3.0.1", 31 | "ts-loader": "^9.2.6", 32 | "typescript": "^4.5.4", 33 | "webpack-notifier": "^1.6.0" 34 | }, 35 | "scripts": { 36 | "dev-server": "encore dev-server", 37 | "dev": "encore dev", 38 | "watch": "encore dev --watch", 39 | "build": "encore production --progress" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /minting-dapp/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; -------------------------------------------------------------------------------- /minting-dapp/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 |
19 |
20 | 21 |
22 | 23 | -------------------------------------------------------------------------------- /minting-dapp/src/images/fav.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashlips-lab/nft-erc721-collection/bad31bcbd24cacc49a8515a10131c3e2f22b92f7/minting-dapp/src/images/fav.png -------------------------------------------------------------------------------- /minting-dapp/src/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashlips-lab/nft-erc721-collection/bad31bcbd24cacc49a8515a10131c3e2f22b92f7/minting-dapp/src/images/logo.png -------------------------------------------------------------------------------- /minting-dapp/src/images/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashlips-lab/nft-erc721-collection/bad31bcbd24cacc49a8515a10131c3e2f22b92f7/minting-dapp/src/images/preview.png -------------------------------------------------------------------------------- /minting-dapp/src/scripts/lib/NftContractType.ts: -------------------------------------------------------------------------------- 1 | // The name below ("YourNftToken") should match the name of your Solidity contract. 2 | // It can be updated using the following command: 3 | // yarn rename-contract NEW_CONTRACT_NAME 4 | // Please DO NOT change it manually! 5 | import { YourNftToken as NftContractType } from '../../../../smart-contract/typechain/index'; 6 | 7 | export default NftContractType; 8 | -------------------------------------------------------------------------------- /minting-dapp/src/scripts/lib/Whitelist.ts: -------------------------------------------------------------------------------- 1 | import whitelistAddresses from '../../../../smart-contract/config/whitelist.json'; 2 | import { MerkleTree } from 'merkletreejs'; 3 | import keccak256 from 'keccak256'; 4 | 5 | export default new class Whitelist { 6 | private merkleTree!: MerkleTree; 7 | 8 | private getMerkleTree(): MerkleTree 9 | { 10 | if (this.merkleTree === undefined) { 11 | const leafNodes = whitelistAddresses.map(addr => keccak256(addr)); 12 | 13 | this.merkleTree = new MerkleTree(leafNodes, keccak256, { sortPairs: true }); 14 | } 15 | 16 | return this.merkleTree; 17 | } 18 | 19 | public getProofForAddress(address: string): string[] 20 | { 21 | return this.getMerkleTree().getHexProof(keccak256(address)); 22 | } 23 | 24 | public getRawProofForAddress(address: string): string 25 | { 26 | return this.getProofForAddress(address).toString().replaceAll('\'', '').replaceAll(' ', ''); 27 | } 28 | 29 | public contains(address: string): boolean 30 | { 31 | return this.getMerkleTree().getLeafIndex(Buffer.from(keccak256(address))) >= 0; 32 | } 33 | }; -------------------------------------------------------------------------------- /minting-dapp/src/scripts/main.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/main.scss'; 2 | import 'react-toastify/dist/ReactToastify.css'; 3 | 4 | import ReactDOM from 'react-dom'; 5 | import Dapp from './react/Dapp'; 6 | import CollectionConfig from '../../../smart-contract/config/CollectionConfig'; 7 | import { ToastContainer } from 'react-toastify'; 8 | 9 | if (document.title === '') { 10 | document.title = CollectionConfig.tokenName; 11 | } 12 | 13 | document.addEventListener('DOMContentLoaded', async () => { 14 | ReactDOM.render(<> 15 | 21 | , document.getElementById('notifications')); 22 | 23 | ReactDOM.render(<> 24 | 25 | , document.getElementById('minting-dapp')); 26 | }); 27 | -------------------------------------------------------------------------------- /minting-dapp/src/scripts/react/CollectionStatus.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Props { 4 | userAddress: string|null; 5 | totalSupply: number; 6 | maxSupply: number; 7 | isPaused: boolean; 8 | isWhitelistMintEnabled: boolean; 9 | isUserInWhitelist: boolean; 10 | isSoldOut: boolean; 11 | } 12 | 13 | interface State { 14 | } 15 | 16 | const defaultState: State = { 17 | }; 18 | 19 | export default class CollectionStatus extends React.Component { 20 | constructor(props: Props) { 21 | super(props); 22 | 23 | this.state = defaultState; 24 | } 25 | 26 | private isSaleOpen(): boolean 27 | { 28 | return (this.props.isWhitelistMintEnabled || !this.props.isPaused) && !this.props.isSoldOut; 29 | } 30 | 31 | render() { 32 | return ( 33 | <> 34 |
35 |
36 | Wallet address: 37 | {this.props.userAddress} 38 |
39 | 40 |
41 | Supply 42 | {this.props.totalSupply}/{this.props.maxSupply} 43 |
44 | 45 |
46 | Sale status 47 | {this.isSaleOpen() ? 48 | <> 49 | {this.props.isWhitelistMintEnabled ? 'Whitelist only' : 'Open'} 50 | 51 | : 52 | 'Closed' 53 | } 54 |
55 |
56 | 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /minting-dapp/src/scripts/react/Dapp.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ethers, BigNumber } from 'ethers' 3 | import { ExternalProvider, Web3Provider } from '@ethersproject/providers'; 4 | import detectEthereumProvider from '@metamask/detect-provider'; 5 | import NftContractType from '../lib/NftContractType'; 6 | import CollectionConfig from '../../../../smart-contract/config/CollectionConfig'; 7 | import NetworkConfigInterface from '../../../../smart-contract/lib/NetworkConfigInterface'; 8 | import CollectionStatus from './CollectionStatus'; 9 | import MintWidget from './MintWidget'; 10 | import Whitelist from '../lib/Whitelist'; 11 | import { toast } from 'react-toastify'; 12 | 13 | const ContractAbi = require('../../../../smart-contract/artifacts/contracts/' + CollectionConfig.contractName + '.sol/' + CollectionConfig.contractName + '.json').abi; 14 | 15 | interface Props { 16 | } 17 | 18 | interface State { 19 | userAddress: string|null; 20 | network: ethers.providers.Network|null; 21 | networkConfig: NetworkConfigInterface; 22 | totalSupply: number; 23 | maxSupply: number; 24 | maxMintAmountPerTx: number; 25 | tokenPrice: BigNumber; 26 | isPaused: boolean; 27 | loading: boolean; 28 | isWhitelistMintEnabled: boolean; 29 | isUserInWhitelist: boolean; 30 | merkleProofManualAddress: string; 31 | merkleProofManualAddressFeedbackMessage: string|JSX.Element|null; 32 | errorMessage: string|JSX.Element|null; 33 | } 34 | 35 | const defaultState: State = { 36 | userAddress: null, 37 | network: null, 38 | networkConfig: CollectionConfig.mainnet, 39 | totalSupply: 0, 40 | maxSupply: 0, 41 | maxMintAmountPerTx: 0, 42 | tokenPrice: BigNumber.from(0), 43 | isPaused: true, 44 | loading: false, 45 | isWhitelistMintEnabled: false, 46 | isUserInWhitelist: false, 47 | merkleProofManualAddress: '', 48 | merkleProofManualAddressFeedbackMessage: null, 49 | errorMessage: null, 50 | }; 51 | 52 | export default class Dapp extends React.Component { 53 | provider!: Web3Provider; 54 | 55 | contract!: NftContractType; 56 | 57 | private merkleProofManualAddressInput!: HTMLInputElement; 58 | 59 | constructor(props: Props) { 60 | super(props); 61 | 62 | this.state = defaultState; 63 | } 64 | 65 | componentDidMount = async () => { 66 | const browserProvider = await detectEthereumProvider() as ExternalProvider; 67 | 68 | if (browserProvider?.isMetaMask !== true) { 69 | this.setError( 70 | <> 71 | We were not able to detect MetaMask. We value privacy and security a lot so we limit the wallet options on the DAPP.
72 |
73 | But don't worry! 😃 You can always interact with the smart-contract through {this.state.networkConfig.blockExplorer.name} and we do our best to provide you with the best user experience possible, even from there.
74 |
75 | You can also get your Whitelist Proof manually, using the tool below. 76 | , 77 | ); 78 | } 79 | 80 | this.provider = new ethers.providers.Web3Provider(browserProvider); 81 | 82 | this.registerWalletEvents(browserProvider); 83 | 84 | await this.initWallet(); 85 | } 86 | 87 | async mintTokens(amount: number): Promise 88 | { 89 | try { 90 | this.setState({loading: true}); 91 | const transaction = await this.contract.mint(amount, {value: this.state.tokenPrice.mul(amount)}); 92 | 93 | toast.info(<> 94 | Transaction sent! Please wait...
95 | View on {this.state.networkConfig.blockExplorer.name} 96 | ); 97 | 98 | const receipt = await transaction.wait(); 99 | 100 | toast.success(<> 101 | Success!
102 | View on {this.state.networkConfig.blockExplorer.name} 103 | ); 104 | 105 | this.refreshContractState(); 106 | this.setState({loading: false}); 107 | } catch (e) { 108 | this.setError(e); 109 | this.setState({loading: false}); 110 | } 111 | } 112 | 113 | async whitelistMintTokens(amount: number): Promise 114 | { 115 | try { 116 | this.setState({loading: true}); 117 | const transaction = await this.contract.whitelistMint(amount, Whitelist.getProofForAddress(this.state.userAddress!), {value: this.state.tokenPrice.mul(amount)}); 118 | 119 | toast.info(<> 120 | Transaction sent! Please wait...
121 | View on {this.state.networkConfig.blockExplorer.name} 122 | ); 123 | 124 | const receipt = await transaction.wait(); 125 | 126 | toast.success(<> 127 | Success!
128 | View on {this.state.networkConfig.blockExplorer.name} 129 | ); 130 | 131 | this.refreshContractState(); 132 | this.setState({loading: false}); 133 | } catch (e) { 134 | this.setError(e); 135 | this.setState({loading: false}); 136 | } 137 | } 138 | 139 | private isWalletConnected(): boolean 140 | { 141 | return this.state.userAddress !== null; 142 | } 143 | 144 | private isContractReady(): boolean 145 | { 146 | return this.contract !== undefined; 147 | } 148 | 149 | private isSoldOut(): boolean 150 | { 151 | return this.state.maxSupply !== 0 && this.state.totalSupply >= this.state.maxSupply; 152 | } 153 | 154 | private isNotMainnet(): boolean 155 | { 156 | return this.state.network !== null && this.state.network.chainId !== CollectionConfig.mainnet.chainId; 157 | } 158 | 159 | private copyMerkleProofToClipboard(): void 160 | { 161 | const merkleProof = Whitelist.getRawProofForAddress(this.state.userAddress ?? this.state.merkleProofManualAddress); 162 | 163 | if (merkleProof.length < 1) { 164 | this.setState({ 165 | merkleProofManualAddressFeedbackMessage: 'The given address is not in the whitelist, please double-check.', 166 | }); 167 | 168 | return; 169 | } 170 | 171 | navigator.clipboard.writeText(merkleProof); 172 | 173 | this.setState({ 174 | merkleProofManualAddressFeedbackMessage: 175 | <> 176 | Congratulations! 🎉
177 | Your Merkle Proof has been copied to the clipboard. You can paste it into {this.state.networkConfig.blockExplorer.name} to claim your tokens. 178 | , 179 | }); 180 | } 181 | 182 | render() { 183 | return ( 184 | <> 185 | {this.isNotMainnet() ? 186 |
187 | You are not connected to the main network. 188 | Current network: {this.state.network?.name} 189 |
190 | : null} 191 | 192 | {this.state.errorMessage ?

{this.state.errorMessage}

: null} 193 | 194 | {this.isWalletConnected() ? 195 | <> 196 | {this.isContractReady() ? 197 | <> 198 | 207 | {!this.isSoldOut() ? 208 | this.mintTokens(mintAmount)} 218 | whitelistMintTokens={(mintAmount) => this.whitelistMintTokens(mintAmount)} 219 | loading={this.state.loading} 220 | /> 221 | : 222 |
223 |

Tokens have been sold out! 🥳

224 | 225 | You can buy from our beloved holders on {CollectionConfig.marketplaceConfig.name}. 226 |
227 | } 228 | 229 | : 230 |
231 | 232 | 233 | 234 | 235 | 236 | Loading collection data... 237 |
238 | } 239 | 240 | : 241 |
242 | {!this.isWalletConnected() ? : null} 243 | 244 |
245 | Hey, looking for a super-safe experience? 😃
246 | You can interact with the smart-contract directly through {this.state.networkConfig.blockExplorer.name}, without even connecting your wallet to this DAPP! 🚀
247 |
248 | Keep safe! ❤️ 249 |
250 | 251 | {!this.isWalletConnected() || this.state.isWhitelistMintEnabled ? 252 |
253 |

Whitelist Proof

254 |

255 | Anyone can generate the proof using any public address in the list, but only the owner of that address will be able to make a successful transaction by using it. 256 |

257 | 258 | {this.state.merkleProofManualAddressFeedbackMessage ?
{this.state.merkleProofManualAddressFeedbackMessage}
: null} 259 | 260 | 261 | this.merkleProofManualAddressInput = input!} onChange={() => {this.setState({merkleProofManualAddress: this.merkleProofManualAddressInput.value})}} /> 262 |
263 | : null} 264 |
265 | } 266 | 267 | ); 268 | } 269 | 270 | private setError(error: any = null): void 271 | { 272 | let errorMessage = 'Unknown error...'; 273 | 274 | if (null === error || typeof error === 'string') { 275 | errorMessage = error; 276 | } else if (typeof error === 'object') { 277 | // Support any type of error from the Web3 Provider... 278 | if (error?.error?.message !== undefined) { 279 | errorMessage = error.error.message; 280 | } else if (error?.data?.message !== undefined) { 281 | errorMessage = error.data.message; 282 | } else if (error?.message !== undefined) { 283 | errorMessage = error.message; 284 | } else if (React.isValidElement(error)) { 285 | this.setState({errorMessage: error}); 286 | 287 | return; 288 | } 289 | } 290 | 291 | this.setState({ 292 | errorMessage: null === errorMessage ? null : errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1), 293 | }); 294 | } 295 | 296 | private generateContractUrl(): string 297 | { 298 | return this.state.networkConfig.blockExplorer.generateContractUrl(CollectionConfig.contractAddress!); 299 | } 300 | 301 | private generateMarketplaceUrl(): string 302 | { 303 | return CollectionConfig.marketplaceConfig.generateCollectionUrl(CollectionConfig.marketplaceIdentifier, !this.isNotMainnet()); 304 | } 305 | 306 | private generateTransactionUrl(transactionHash: string): string 307 | { 308 | return this.state.networkConfig.blockExplorer.generateTransactionUrl(transactionHash); 309 | } 310 | 311 | private async connectWallet(): Promise 312 | { 313 | try { 314 | await this.provider.provider.request!({ method: 'eth_requestAccounts' }); 315 | 316 | this.initWallet(); 317 | } catch (e) { 318 | this.setError(e); 319 | } 320 | } 321 | 322 | private async refreshContractState(): Promise 323 | { 324 | this.setState({ 325 | maxSupply: (await this.contract.maxSupply()).toNumber(), 326 | totalSupply: (await this.contract.totalSupply()).toNumber(), 327 | maxMintAmountPerTx: (await this.contract.maxMintAmountPerTx()).toNumber(), 328 | tokenPrice: await this.contract.cost(), 329 | isPaused: await this.contract.paused(), 330 | isWhitelistMintEnabled: await this.contract.whitelistMintEnabled(), 331 | isUserInWhitelist: Whitelist.contains(this.state.userAddress ?? ''), 332 | }); 333 | } 334 | 335 | private async initWallet(): Promise 336 | { 337 | const walletAccounts = await this.provider.listAccounts(); 338 | 339 | this.setState(defaultState); 340 | 341 | if (walletAccounts.length === 0) { 342 | return; 343 | } 344 | 345 | const network = await this.provider.getNetwork(); 346 | let networkConfig: NetworkConfigInterface; 347 | 348 | if (network.chainId === CollectionConfig.mainnet.chainId) { 349 | networkConfig = CollectionConfig.mainnet; 350 | } else if (network.chainId === CollectionConfig.testnet.chainId) { 351 | networkConfig = CollectionConfig.testnet; 352 | } else { 353 | this.setError('Unsupported network!'); 354 | 355 | return; 356 | } 357 | 358 | this.setState({ 359 | userAddress: walletAccounts[0], 360 | network, 361 | networkConfig, 362 | }); 363 | 364 | if (await this.provider.getCode(CollectionConfig.contractAddress!) === '0x') { 365 | this.setError('Could not find the contract, are you connected to the right chain?'); 366 | 367 | return; 368 | } 369 | 370 | this.contract = new ethers.Contract( 371 | CollectionConfig.contractAddress!, 372 | ContractAbi, 373 | this.provider.getSigner(), 374 | ) as NftContractType; 375 | 376 | this.refreshContractState(); 377 | } 378 | 379 | private registerWalletEvents(browserProvider: ExternalProvider): void 380 | { 381 | // @ts-ignore 382 | browserProvider.on('accountsChanged', () => { 383 | this.initWallet(); 384 | }); 385 | 386 | // @ts-ignore 387 | browserProvider.on('chainChanged', () => { 388 | window.location.reload(); 389 | }); 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /minting-dapp/src/scripts/react/MintWidget.tsx: -------------------------------------------------------------------------------- 1 | import { utils, BigNumber } from 'ethers'; 2 | import React from 'react'; 3 | import NetworkConfigInterface from '../../../../smart-contract/lib/NetworkConfigInterface'; 4 | 5 | interface Props { 6 | networkConfig: NetworkConfigInterface; 7 | maxSupply: number; 8 | totalSupply: number; 9 | tokenPrice: BigNumber; 10 | maxMintAmountPerTx: number; 11 | isPaused: boolean; 12 | loading: boolean; 13 | isWhitelistMintEnabled: boolean; 14 | isUserInWhitelist: boolean; 15 | mintTokens(mintAmount: number): Promise; 16 | whitelistMintTokens(mintAmount: number): Promise; 17 | } 18 | 19 | interface State { 20 | mintAmount: number; 21 | } 22 | 23 | const defaultState: State = { 24 | mintAmount: 1, 25 | }; 26 | 27 | export default class MintWidget extends React.Component { 28 | constructor(props: Props) { 29 | super(props); 30 | 31 | this.state = defaultState; 32 | } 33 | 34 | private canMint(): boolean { 35 | return !this.props.isPaused || this.canWhitelistMint(); 36 | } 37 | 38 | private canWhitelistMint(): boolean { 39 | return this.props.isWhitelistMintEnabled && this.props.isUserInWhitelist; 40 | } 41 | 42 | private incrementMintAmount(): void { 43 | this.setState({ 44 | mintAmount: Math.min(this.props.maxMintAmountPerTx, this.state.mintAmount + 1), 45 | }); 46 | } 47 | 48 | private decrementMintAmount(): void { 49 | this.setState({ 50 | mintAmount: Math.max(1, this.state.mintAmount - 1), 51 | }); 52 | } 53 | 54 | private async mint(): Promise { 55 | if (!this.props.isPaused) { 56 | await this.props.mintTokens(this.state.mintAmount); 57 | 58 | return; 59 | } 60 | 61 | await this.props.whitelistMintTokens(this.state.mintAmount); 62 | } 63 | 64 | render() { 65 | return ( 66 | <> 67 | {this.canMint() ? 68 |
69 |
70 | Collection preview 71 |
72 | 73 |
74 | Total price: {utils.formatEther(this.props.tokenPrice.mul(this.state.mintAmount))} {this.props.networkConfig.symbol} 75 |
76 | 77 |
78 | 79 | {this.state.mintAmount} 80 | 81 | 82 |
83 |
84 | : 85 |
86 | 87 | 88 | {this.props.isWhitelistMintEnabled ? <>You are not included in the whitelist. : <>The contract is paused.}
89 | Please come back during the next sale! 90 |
91 | } 92 | 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /minting-dapp/src/styles/components/general.scss: -------------------------------------------------------------------------------- 1 | body { 2 | @apply p-6; 3 | @apply min-h-screen; 4 | 5 | @apply font-sans; 6 | 7 | // Simple background with color gradient 8 | @apply bg-gradient-to-b from-page-from_bg via-page-from_bg to-page-to_bg; 9 | 10 | // Fullscreen background image example 11 | //background-image: url('../../images/background.jpg'); 12 | //@apply bg-center bg-cover bg-fixed; 13 | } 14 | 15 | a, a:link, a:visited { 16 | @apply font-semibold; 17 | @apply text-links-txt; 18 | 19 | &:hover { 20 | @apply underline; 21 | @apply text-links-hover_txt; 22 | } 23 | } 24 | 25 | strong { 26 | @apply font-semibold; 27 | } 28 | 29 | main { 30 | @apply flex flex-col; 31 | 32 | #logo { 33 | @apply m-auto; 34 | @apply w-full; 35 | @apply max-w-md; 36 | } 37 | 38 | span.emoji { 39 | @apply text-2xl; 40 | } 41 | 42 | .error { 43 | @apply flex flex-col; 44 | @apply rounded-lg; 45 | @apply p-3; 46 | 47 | @apply text-error-txt text-sm; 48 | @apply bg-error-bg; 49 | @apply border border-error-border; 50 | @apply shadow; 51 | 52 | &::before { 53 | content: 'Error'; 54 | 55 | @apply font-semibold; 56 | @apply text-base; 57 | } 58 | 59 | button { 60 | @apply inline-block; 61 | @apply mt-3 ml-auto; 62 | @apply px-2 py-1; 63 | @apply rounded-md; 64 | 65 | @apply font-semibold; 66 | @apply text-btn_error-txt text-xs; 67 | @apply bg-btn_error-bg; 68 | @apply border-btn_error-border; 69 | 70 | &:hover { 71 | @apply text-btn_error-hover_txt; 72 | @apply bg-btn_error-hover_bg; 73 | @apply border-btn_error-hover_border; 74 | } 75 | } 76 | } 77 | 78 | button { 79 | @apply py-2 px-6; 80 | 81 | @apply rounded-full; 82 | 83 | @apply font-semibold; 84 | @apply text-btn-txt; 85 | @apply bg-btn-bg; 86 | @apply border border-btn-border; 87 | @apply shadow-sm; 88 | 89 | &:hover { 90 | @apply text-btn-hover_txt; 91 | @apply bg-btn-hover_bg; 92 | @apply border-btn-hover_border; 93 | } 94 | 95 | &.primary { 96 | @apply text-btn_primary-txt; 97 | @apply bg-btn_primary-bg; 98 | @apply border-btn_primary-border; 99 | 100 | &:hover { 101 | @apply text-btn_primary-hover_txt; 102 | @apply bg-btn_primary-hover_bg; 103 | @apply border-btn_primary-hover_border; 104 | } 105 | 106 | &:disabled { 107 | @apply opacity-30; 108 | 109 | &:hover { 110 | @apply cursor-not-allowed; 111 | } 112 | } 113 | } 114 | } 115 | 116 | input[type=text] { 117 | @apply py-2 px-4; 118 | 119 | @apply rounded-full; 120 | 121 | @apply font-mono font-semibold; 122 | @apply text-txt_input-txt; 123 | @apply bg-txt_input-bg; 124 | @apply border border-txt_input-border; 125 | @apply shadow-sm; 126 | @apply outline-none; 127 | 128 | &:focus { 129 | @apply text-txt_input-focus_txt; 130 | @apply bg-txt_input-focus_bg; 131 | @apply border-txt_input-focus_border; 132 | } 133 | 134 | &:disabled { 135 | @apply opacity-50; 136 | 137 | &:hover { 138 | @apply cursor-not-allowed; 139 | } 140 | } 141 | 142 | &::placeholder { 143 | @apply text-txt_input-placeholder_txt; 144 | @apply opacity-50; 145 | } 146 | } 147 | 148 | label { 149 | @apply mt-4 mb-1 ml-1; 150 | 151 | @apply font-semibold; 152 | @apply text-label text-sm; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /minting-dapp/src/styles/components/minting-dapp.scss: -------------------------------------------------------------------------------- 1 | #minting-dapp { 2 | @apply flex flex-col gap-6; 3 | @apply mt-6 mx-auto; 4 | @apply w-full; 5 | @apply max-w-md; 6 | 7 | .no-wallet { 8 | @apply flex flex-col; 9 | @apply px-4 py-6; 10 | @apply rounded-lg; 11 | 12 | @apply text-popups-txt; 13 | @apply bg-popups-bg; 14 | @apply shadow; 15 | 16 | .use-block-explorer { 17 | &:not(:first-child) { 18 | @apply mt-3; 19 | } 20 | 21 | &:not(:first-child)::before { 22 | content: ''; 23 | 24 | @apply block; 25 | @apply mx-auto my-3; 26 | @apply w-12; 27 | 28 | @apply border-t-2 border-popups-internal_border; 29 | } 30 | } 31 | 32 | .merkle-proof-manual-address { 33 | @apply flex flex-col; 34 | @apply mt-4; 35 | 36 | h2 { 37 | @apply font-semibold; 38 | @apply text-titles text-xl text-center; 39 | } 40 | 41 | p { 42 | @apply mt-3; 43 | } 44 | 45 | .feedback-message { 46 | @apply rounded-lg; 47 | @apply mt-4; 48 | @apply p-3; 49 | 50 | @apply text-wl_message-txt text-sm; 51 | @apply bg-wl_message-bg; 52 | } 53 | 54 | input { 55 | @apply rounded-t-lg; 56 | @apply rounded-b-none; 57 | } 58 | 59 | button { 60 | @apply rounded-b-lg; 61 | @apply rounded-t-none; 62 | @apply border-t-0; 63 | } 64 | } 65 | } 66 | 67 | .collection-not-ready { 68 | @apply flex items-center justify-center; 69 | @apply px-6 py-4; 70 | @apply rounded-lg; 71 | 72 | @apply text-popups-txt text-sm; 73 | @apply bg-popups-bg; 74 | @apply shadow; 75 | 76 | .spinner { 77 | @apply inline; 78 | @apply -ml-1 mr-3 h-8 w-8 text-loading_spinner; 79 | @apply animate-spin; 80 | } 81 | } 82 | 83 | .collection-status { 84 | @apply grid sm:grid-cols-2 auto-rows-min; 85 | @apply rounded-lg; 86 | 87 | @apply font-mono; 88 | @apply text-popups-txt text-sm; 89 | @apply bg-popups-bg; 90 | @apply shadow; 91 | 92 | & > * { 93 | @apply flex flex-col items-center; 94 | @apply px-6 py-4; 95 | 96 | .label { 97 | @apply text-xs text-label; 98 | } 99 | } 100 | 101 | .user-address { 102 | @apply sm:col-span-2; 103 | @apply overflow-hidden; 104 | 105 | @apply border-b border-popups-internal_border; 106 | 107 | .address { 108 | @apply w-full; 109 | 110 | @apply font-semibold; 111 | @apply truncate; 112 | @apply text-center; 113 | } 114 | } 115 | 116 | .supply, .current-sale { 117 | .label { 118 | @apply block; 119 | 120 | @apply font-semibold; 121 | } 122 | 123 | &.supply { 124 | @apply border-b sm:border-b-0 sm:border-r border-popups-internal_border; 125 | } 126 | } 127 | } 128 | 129 | .cannot-mint, .not-mainnet, .collection-sold-out { 130 | @apply rounded-lg; 131 | @apply px-6 py-4; 132 | 133 | @apply text-popups-txt text-center; 134 | @apply bg-popups-bg; 135 | @apply shadow; 136 | 137 | &.cannot-mint .emoji { 138 | @apply block; 139 | 140 | @apply text-4xl; 141 | } 142 | 143 | &.not-mainnet { 144 | @apply text-warning-txt; 145 | @apply bg-warning-bg; 146 | @apply border border-warning-border; 147 | 148 | 149 | .small { 150 | @apply block; 151 | 152 | @apply text-sm; 153 | } 154 | } 155 | 156 | &.collection-sold-out { 157 | h2 { 158 | @apply mb-3; 159 | 160 | @apply text-xl; 161 | } 162 | } 163 | } 164 | 165 | .mint-widget { 166 | @apply flex flex-col items-center; 167 | @apply rounded-lg; 168 | @apply overflow-hidden; 169 | 170 | @apply text-popups-txt text-center; 171 | @apply bg-popups-bg; 172 | @apply shadow; 173 | 174 | .preview { 175 | @apply p-8; 176 | 177 | @apply bg-token_preview; 178 | 179 | img { 180 | @apply m-auto; 181 | @apply max-h-52; 182 | 183 | filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.4)); 184 | } 185 | } 186 | 187 | .price { 188 | @apply px-6 py-4; 189 | } 190 | 191 | & > * { 192 | @apply w-full; 193 | 194 | &:not(:last-child) { 195 | @apply border-b border-popups-internal_border; 196 | } 197 | } 198 | 199 | .controls { 200 | @apply flex items-stretch; 201 | 202 | & > * { 203 | @apply rounded-none; 204 | @apply border-0; 205 | } 206 | 207 | .decrease, .mint-amount { 208 | @apply border-r border-popups-internal_border; 209 | } 210 | 211 | .mint-amount { 212 | @apply flex items-center justify-center; 213 | @apply w-full; 214 | 215 | @apply font-semibold; 216 | @apply text-label text-lg; 217 | } 218 | 219 | .primary { 220 | @apply border-0; 221 | } 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /minting-dapp/src/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import '~tailwindcss/base'; 2 | @import '~tailwindcss/components'; 3 | @import '~tailwindcss/utilities'; 4 | 5 | @import './components/general.scss'; 6 | @import './components/minting-dapp.scss'; 7 | -------------------------------------------------------------------------------- /minting-dapp/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors'); 2 | 3 | module.exports = { 4 | mode: 'jit', 5 | content: [ 6 | './src/**/*.tsx', 7 | './public/index.html', 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | // General 13 | page: { 14 | from_bg: colors.slate[100], 15 | to_bg: colors.slate[200], 16 | }, 17 | titles: colors.indigo[600], 18 | links: { 19 | txt: colors.indigo[600], 20 | hover_txt: colors.indigo[700], 21 | }, 22 | loading_spinner: colors.indigo[500], 23 | popups: { 24 | bg: colors.white, 25 | txt: colors.slate[800], 26 | internal_border: colors.slate[200], 27 | }, 28 | warning: { 29 | txt: colors.slate[800], 30 | bg: colors.yellow[400], 31 | border: colors.yellow[500], 32 | }, 33 | error: { 34 | txt: colors.red[500], 35 | bg: colors.red[50], 36 | border: colors.red[200], 37 | }, 38 | 39 | // Inputs 40 | btn: { 41 | txt: colors.slate[800], 42 | bg: colors.white, 43 | border: colors.slate[200], 44 | hover_txt: colors.slate[800], 45 | hover_bg: colors.slate[100], 46 | hover_border: colors.slate[200], 47 | }, 48 | btn_primary: { 49 | txt: colors.white, 50 | bg: colors.indigo[500], 51 | border: colors.indigo[500], 52 | hover_txt: colors.white, 53 | hover_bg: colors.indigo[600], 54 | hover_border: colors.indigo[600], 55 | }, 56 | btn_error: { 57 | txt: colors.white, 58 | bg: colors.red[500], 59 | border: colors.red[500], 60 | hover_txt: colors.white, 61 | hover_bg: colors.red[600], 62 | hover_border: colors.red[600], 63 | }, 64 | label: colors.indigo[600], 65 | txt_input: { 66 | txt: colors.indigo[600], 67 | bg: colors.white, 68 | border: colors.slate[200], 69 | focus_txt: colors.indigo[600], 70 | focus_bg: colors.slate[50], 71 | focus_border: colors.indigo[300], 72 | placeholder_txt: colors.indigo[600], 73 | }, 74 | 75 | // Whitelist proof widget 76 | wl_message: { 77 | txt: colors.slate[800], 78 | bg: colors.indigo[100], 79 | }, 80 | 81 | // Mint widget 82 | token_preview: colors.indigo[200], 83 | }, 84 | }, 85 | }, 86 | variants: {}, 87 | plugins: [], 88 | }; 89 | -------------------------------------------------------------------------------- /minting-dapp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "ESNext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | "jsx": "react-jsx", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | // "outDir": "./", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | "resolveJsonModule": true, 53 | 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | 60 | /* Experimental Options */ 61 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 63 | }, 64 | "compileOnSave": false, 65 | "exclude": [ 66 | "../node_modules" 67 | ], 68 | } -------------------------------------------------------------------------------- /minting-dapp/webpack.config.js: -------------------------------------------------------------------------------- 1 | const Encore = require('@symfony/webpack-encore'); 2 | const webpack = require('webpack'); 3 | const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); 4 | 5 | // Manually configure the runtime environment if not already configured yet by the "encore" command. 6 | // It's useful when you use tools that rely on webpack.config.js file. 7 | if (!Encore.isRuntimeEnvironmentConfigured()) { 8 | Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev'); 9 | } 10 | 11 | Encore 12 | // directory where compiled assets will be stored 13 | .setOutputPath('public/build/') 14 | // public path used by the web server to access the output path 15 | .setPublicPath('/build') 16 | // only needed for CDN's or sub-directory deploy 17 | //.setManifestKeyPrefix('build/') 18 | 19 | /* 20 | * ENTRY CONFIG 21 | * 22 | * Each entry will result in one JavaScript file (e.g. app.js) 23 | * and one CSS file (e.g. app.css) if your JavaScript imports CSS. 24 | */ 25 | .addEntry('main', './src/scripts/main.tsx') 26 | 27 | // copy images 28 | .copyFiles({ 29 | from: './src/images', 30 | to: '[path][name].[ext]', 31 | context: './src' 32 | }) 33 | 34 | // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js) 35 | //.enableStimulusBridge('./assets/controllers.json') 36 | 37 | // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. 38 | //.splitEntryChunks() 39 | 40 | // will require an extra script tag for runtime.js 41 | // but, you probably want this, unless you're building a single-page app 42 | //.enableSingleRuntimeChunk() 43 | .disableSingleRuntimeChunk() 44 | 45 | /* 46 | * FEATURE CONFIG 47 | * 48 | * Enable & configure other features below. For a full 49 | * list of features, see: 50 | * https://symfony.com/doc/current/frontend.html#adding-more-features 51 | */ 52 | .cleanupOutputBeforeBuild() 53 | .enableBuildNotifications() 54 | .enableSourceMaps(!Encore.isProduction()) 55 | // enables hashed filenames (e.g. app.abc123.css) 56 | //.enableVersioning(Encore.isProduction()) 57 | 58 | .configureBabel((config) => { 59 | config.plugins.push('@babel/plugin-proposal-class-properties'); 60 | }) 61 | 62 | // enables @babel/preset-env polyfills 63 | .configureBabelPresetEnv((config) => { 64 | config.useBuiltIns = 'usage'; 65 | config.corejs = 3; 66 | }) 67 | 68 | // enables Sass/SCSS support 69 | .enableSassLoader() 70 | 71 | // uncomment if you use TypeScript 72 | .enableTypeScriptLoader() 73 | 74 | // enables PostCSS support 75 | .enablePostCssLoader() 76 | 77 | // uncomment if you use React 78 | .enableReactPreset() 79 | 80 | // uncomment to get integrity="..." attributes on your script & link tags 81 | // requires WebpackEncoreBundle 1.4 or higher 82 | //.enableIntegrityHashes(Encore.isProduction()) 83 | 84 | // uncomment if you're having problems with a jQuery plugin 85 | //.autoProvidejQuery() 86 | 87 | //.addPlugin(new webpack.ProvidePlugin({ 88 | // Buffer: ['buffer', 'Buffer'], 89 | //})) 90 | .addPlugin(new NodePolyfillPlugin()) 91 | ; 92 | 93 | module.exports = Encore.getWebpackConfig(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hashlips-lab/nft-erc721-collection", 3 | "description": "An all-in-one solution for ERC721 collections.", 4 | "author": "Marco Lipparini ", 5 | "license": "MIT", 6 | "version": "2.4.4", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/hashlips-lab/nft-erc721-collection.git" 10 | }, 11 | "scripts": { 12 | "build-dapp": "cd smart-contract; yarn; yarn compile; cd ../minting-dapp; yarn; yarn build" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /smart-contract/.env.example: -------------------------------------------------------------------------------- 1 | COLLECTION_URI_PREFIX=ipfs://__CID___/ 2 | 3 | NETWORK_TESTNET_URL=https://rinkeby.infura.io/v3/abc123abc123abc123abc123abc123ab 4 | # !!! WARNING !!! 5 | # Please manage your .env files carefully when using them to store your private keys. 6 | # People getting access to you private keys will be able to control your wallet forever. 7 | # Remember you can use the CLI commands even without setting the private keys here, please 8 | # check out the GitHub repo for more information. 9 | NETWORK_TESTNET_PRIVATE_KEY=0xabc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1 10 | 11 | NETWORK_MAINNET_URL=https://mainnet.infura.io/v3/abc123abc123abc123abc123abc123ab 12 | # !!! WARNING !!! 13 | # Please manage your .env files carefully when using them to store your private keys. 14 | # People getting access to you private keys will be able to control your wallet forever. 15 | # Remember you can use the CLI commands even without setting the private keys here, please 16 | # check out the GitHub repo for more information. 17 | NETWORK_MAINNET_PRIVATE_KEY=0xabc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1 18 | 19 | GAS_REPORTER_COIN_MARKET_CAP_API_KEY=00000000-0000-0000-0000-000000000000 20 | 21 | BLOCK_EXPLORER_API_KEY=ABC123ABC123ABC123ABC123ABC123ABC1 -------------------------------------------------------------------------------- /smart-contract/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | artifacts 3 | cache 4 | coverage 5 | -------------------------------------------------------------------------------- /smart-contract/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: false, 4 | es2021: true, 5 | mocha: true, 6 | node: true, 7 | }, 8 | plugins: ["@typescript-eslint"], 9 | extends: [ 10 | "standard", 11 | "plugin:prettier/recommended", 12 | "plugin:node/recommended", 13 | ], 14 | parser: "@typescript-eslint/parser", 15 | parserOptions: { 16 | ecmaVersion: 12, 17 | }, 18 | rules: { 19 | "node/no-unsupported-features/es-syntax": [ 20 | "error", 21 | { ignores: ["modules"] }, 22 | ], 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /smart-contract/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | 7 | #Hardhat files 8 | cache 9 | artifacts 10 | -------------------------------------------------------------------------------- /smart-contract/.npmignore: -------------------------------------------------------------------------------- 1 | hardhat.config.ts 2 | scripts 3 | test 4 | -------------------------------------------------------------------------------- /smart-contract/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | artifacts 3 | cache 4 | coverage* 5 | gasReporterOutput.json 6 | -------------------------------------------------------------------------------- /smart-contract/.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "rules": { 4 | "compiler-version": ["error", ">=0.8.9 <0.9.0"], 5 | "func-visibility": ["warn", { "ignoreConstructors": true }] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /smart-contract/.solhintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /smart-contract/config/CollectionConfig.ts: -------------------------------------------------------------------------------- 1 | import CollectionConfigInterface from '../lib/CollectionConfigInterface'; 2 | import * as Networks from '../lib/Networks'; 3 | import * as Marketplaces from '../lib/Marketplaces'; 4 | import whitelistAddresses from './whitelist.json'; 5 | 6 | const CollectionConfig: CollectionConfigInterface = { 7 | testnet: Networks.ethereumTestnet, 8 | mainnet: Networks.ethereumMainnet, 9 | // The contract name can be updated using the following command: 10 | // yarn rename-contract NEW_CONTRACT_NAME 11 | // Please DO NOT change it manually! 12 | contractName: 'YourNftToken', 13 | tokenName: 'My NFT Token', 14 | tokenSymbol: 'MNT', 15 | hiddenMetadataUri: 'ipfs://__CID__/hidden.json', 16 | maxSupply: 10000, 17 | whitelistSale: { 18 | price: 0.05, 19 | maxMintAmountPerTx: 1, 20 | }, 21 | preSale: { 22 | price: 0.07, 23 | maxMintAmountPerTx: 2, 24 | }, 25 | publicSale: { 26 | price: 0.09, 27 | maxMintAmountPerTx: 5, 28 | }, 29 | contractAddress: null, 30 | marketplaceIdentifier: 'my-nft-token', 31 | marketplaceConfig: Marketplaces.openSea, 32 | whitelistAddresses, 33 | }; 34 | 35 | export default CollectionConfig; 36 | -------------------------------------------------------------------------------- /smart-contract/config/ContractArguments.ts: -------------------------------------------------------------------------------- 1 | import { utils } from 'ethers'; 2 | import CollectionConfig from './CollectionConfig'; 3 | 4 | // Update the following array if you change the constructor arguments... 5 | const ContractArguments = [ 6 | CollectionConfig.tokenName, 7 | CollectionConfig.tokenSymbol, 8 | utils.parseEther(CollectionConfig.whitelistSale.price.toString()), 9 | CollectionConfig.maxSupply, 10 | CollectionConfig.whitelistSale.maxMintAmountPerTx, 11 | CollectionConfig.hiddenMetadataUri, 12 | ] as const; 13 | 14 | export default ContractArguments; -------------------------------------------------------------------------------- /smart-contract/config/whitelist.json: -------------------------------------------------------------------------------- 1 | [ 2 | "_REPLACE_EVERYTHING_WITH_REAL_ADDRESSES___", 3 | "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", 4 | "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", 5 | "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", 6 | "0x976EA74026E726554dB657fA54763abd0C3a0aa9", 7 | "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955", 8 | "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f", 9 | "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", 10 | "0xBcd4042DE499D14e55001CcbB24a551F3b954096", 11 | "0x71bE63f3384f5fb98995898A86B02Fb2426c5788", 12 | "0xFABB0ac9d68B0B445fB7357272Ff202C5651694a", 13 | "0x1CBd3b2770909D4e10f157cABC84C7264073C9Ec", 14 | "0xdF3e18d64BC6A983f673Ab319CCaE4f1a57C7097", 15 | "0xcd3B766CCDd6AE721141F452C550Ca635964ce71", 16 | "0x2546BcD3c84621e976D8185a91A922aE77ECEc30", 17 | "0xbDA5747bFD65F08deb54cb465eB87D40e51B197E", 18 | "0xdD2FD4581271e230360230F9337D5c0430Bf44C0", 19 | "0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199" 20 | ] -------------------------------------------------------------------------------- /smart-contract/contracts/YourNftToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity >=0.8.9 <0.9.0; 4 | 5 | import 'erc721a/contracts/extensions/ERC721AQueryable.sol'; 6 | import '@openzeppelin/contracts/access/Ownable.sol'; 7 | import '@openzeppelin/contracts/utils/cryptography/MerkleProof.sol'; 8 | import '@openzeppelin/contracts/security/ReentrancyGuard.sol'; 9 | 10 | contract YourNftToken is ERC721AQueryable, Ownable, ReentrancyGuard { 11 | 12 | using Strings for uint256; 13 | 14 | bytes32 public merkleRoot; 15 | mapping(address => bool) public whitelistClaimed; 16 | 17 | string public uriPrefix = ''; 18 | string public uriSuffix = '.json'; 19 | string public hiddenMetadataUri; 20 | 21 | uint256 public cost; 22 | uint256 public maxSupply; 23 | uint256 public maxMintAmountPerTx; 24 | 25 | bool public paused = true; 26 | bool public whitelistMintEnabled = false; 27 | bool public revealed = false; 28 | 29 | constructor( 30 | string memory _tokenName, 31 | string memory _tokenSymbol, 32 | uint256 _cost, 33 | uint256 _maxSupply, 34 | uint256 _maxMintAmountPerTx, 35 | string memory _hiddenMetadataUri 36 | ) ERC721A(_tokenName, _tokenSymbol) { 37 | setCost(_cost); 38 | maxSupply = _maxSupply; 39 | setMaxMintAmountPerTx(_maxMintAmountPerTx); 40 | setHiddenMetadataUri(_hiddenMetadataUri); 41 | } 42 | 43 | modifier mintCompliance(uint256 _mintAmount) { 44 | require(_mintAmount > 0 && _mintAmount <= maxMintAmountPerTx, 'Invalid mint amount!'); 45 | require(totalSupply() + _mintAmount <= maxSupply, 'Max supply exceeded!'); 46 | _; 47 | } 48 | 49 | modifier mintPriceCompliance(uint256 _mintAmount) { 50 | require(msg.value >= cost * _mintAmount, 'Insufficient funds!'); 51 | _; 52 | } 53 | 54 | function whitelistMint(uint256 _mintAmount, bytes32[] calldata _merkleProof) public payable mintCompliance(_mintAmount) mintPriceCompliance(_mintAmount) { 55 | // Verify whitelist requirements 56 | require(whitelistMintEnabled, 'The whitelist sale is not enabled!'); 57 | require(!whitelistClaimed[_msgSender()], 'Address already claimed!'); 58 | bytes32 leaf = keccak256(abi.encodePacked(_msgSender())); 59 | require(MerkleProof.verify(_merkleProof, merkleRoot, leaf), 'Invalid proof!'); 60 | 61 | whitelistClaimed[_msgSender()] = true; 62 | _safeMint(_msgSender(), _mintAmount); 63 | } 64 | 65 | function mint(uint256 _mintAmount) public payable mintCompliance(_mintAmount) mintPriceCompliance(_mintAmount) { 66 | require(!paused, 'The contract is paused!'); 67 | 68 | _safeMint(_msgSender(), _mintAmount); 69 | } 70 | 71 | function mintForAddress(uint256 _mintAmount, address _receiver) public mintCompliance(_mintAmount) onlyOwner { 72 | _safeMint(_receiver, _mintAmount); 73 | } 74 | 75 | function _startTokenId() internal view virtual override returns (uint256) { 76 | return 1; 77 | } 78 | 79 | function tokenURI(uint256 _tokenId) public view virtual override returns (string memory) { 80 | require(_exists(_tokenId), 'ERC721Metadata: URI query for nonexistent token'); 81 | 82 | if (revealed == false) { 83 | return hiddenMetadataUri; 84 | } 85 | 86 | string memory currentBaseURI = _baseURI(); 87 | return bytes(currentBaseURI).length > 0 88 | ? string(abi.encodePacked(currentBaseURI, _tokenId.toString(), uriSuffix)) 89 | : ''; 90 | } 91 | 92 | function setRevealed(bool _state) public onlyOwner { 93 | revealed = _state; 94 | } 95 | 96 | function setCost(uint256 _cost) public onlyOwner { 97 | cost = _cost; 98 | } 99 | 100 | function setMaxMintAmountPerTx(uint256 _maxMintAmountPerTx) public onlyOwner { 101 | maxMintAmountPerTx = _maxMintAmountPerTx; 102 | } 103 | 104 | function setHiddenMetadataUri(string memory _hiddenMetadataUri) public onlyOwner { 105 | hiddenMetadataUri = _hiddenMetadataUri; 106 | } 107 | 108 | function setUriPrefix(string memory _uriPrefix) public onlyOwner { 109 | uriPrefix = _uriPrefix; 110 | } 111 | 112 | function setUriSuffix(string memory _uriSuffix) public onlyOwner { 113 | uriSuffix = _uriSuffix; 114 | } 115 | 116 | function setPaused(bool _state) public onlyOwner { 117 | paused = _state; 118 | } 119 | 120 | function setMerkleRoot(bytes32 _merkleRoot) public onlyOwner { 121 | merkleRoot = _merkleRoot; 122 | } 123 | 124 | function setWhitelistMintEnabled(bool _state) public onlyOwner { 125 | whitelistMintEnabled = _state; 126 | } 127 | 128 | function withdraw() public onlyOwner nonReentrant { 129 | // This will pay HashLips Lab Team 5% of the initial sale. 130 | // By leaving the following lines as they are you will contribute to the 131 | // development of tools like this and many others. 132 | // ============================================================================= 133 | (bool hs, ) = payable(0x146FB9c3b2C13BA88c6945A759EbFa95127486F4).call{value: address(this).balance * 5 / 100}(''); 134 | require(hs); 135 | // ============================================================================= 136 | 137 | // This will transfer the remaining contract balance to the owner. 138 | // Do not remove this otherwise you will not be able to withdraw the funds. 139 | // ============================================================================= 140 | (bool os, ) = payable(owner()).call{value: address(this).balance}(''); 141 | require(os); 142 | // ============================================================================= 143 | } 144 | 145 | function _baseURI() internal view virtual override returns (string memory) { 146 | return uriPrefix; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /smart-contract/hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import * as dotenv from 'dotenv'; 3 | import { HardhatUserConfig, task } from 'hardhat/config'; 4 | import { MerkleTree } from 'merkletreejs'; 5 | import keccak256 from 'keccak256'; 6 | import '@nomiclabs/hardhat-etherscan'; 7 | import '@nomiclabs/hardhat-waffle'; 8 | import '@typechain/hardhat'; 9 | import 'hardhat-gas-reporter'; 10 | import 'solidity-coverage'; 11 | import CollectionConfig from './config/CollectionConfig'; 12 | 13 | dotenv.config(); 14 | 15 | /* 16 | * If you have issues with stuck transactions or you simply want to invest in 17 | * higher gas fees in order to make sure your transactions will run smoother 18 | * and faster, then you can update the followind value. 19 | * This value is used by default in any network defined in this project, but 20 | * please make sure to add it manually if you define any custom network. 21 | * 22 | * Example: 23 | * Setting the value to "1.1" will raise the gas values by 10% compared to the 24 | * estimated value. 25 | */ 26 | const DEFAULT_GAS_MULTIPLIER: number = 1; 27 | 28 | // This is a sample Hardhat task. To learn how to create your own go to 29 | // https://hardhat.org/guides/create-task.html 30 | task('accounts', 'Prints the list of accounts', async (taskArgs, hre) => { 31 | const accounts = await hre.ethers.getSigners(); 32 | 33 | for (const account of accounts) { 34 | console.log(account.address); 35 | } 36 | }); 37 | 38 | task('generate-root-hash', 'Generates and prints out the root hash for the current whitelist', async () => { 39 | // Check configuration 40 | if (CollectionConfig.whitelistAddresses.length < 1) { 41 | throw 'The whitelist is empty, please add some addresses to the configuration.'; 42 | } 43 | 44 | // Build the Merkle Tree 45 | const leafNodes = CollectionConfig.whitelistAddresses.map(addr => keccak256(addr)); 46 | const merkleTree = new MerkleTree(leafNodes, keccak256, { sortPairs: true }); 47 | const rootHash = '0x' + merkleTree.getRoot().toString('hex'); 48 | 49 | console.log('The Merkle Tree root hash for the current whitelist is: ' + rootHash); 50 | }); 51 | 52 | task('generate-proof', 'Generates and prints out the whitelist proof for the given address (compatible with block explorers such as Etherscan)', async (taskArgs: {address: string}) => { 53 | // Check configuration 54 | if (CollectionConfig.whitelistAddresses.length < 1) { 55 | throw 'The whitelist is empty, please add some addresses to the configuration.'; 56 | } 57 | 58 | // Build the Merkle Tree 59 | const leafNodes = CollectionConfig.whitelistAddresses.map(addr => keccak256(addr)); 60 | const merkleTree = new MerkleTree(leafNodes, keccak256, { sortPairs: true }); 61 | const proof = merkleTree.getHexProof(keccak256(taskArgs.address)).toString().replace(/'/g, '').replace(/ /g, ''); 62 | 63 | console.log('The whitelist proof for the given address is: ' + proof); 64 | }) 65 | .addPositionalParam('address', 'The public address'); 66 | 67 | task('rename-contract', 'Renames the smart contract replacing all occurrences in source files', async (taskArgs: {newName: string}, hre) => { 68 | // Validate new name 69 | if (!/^([A-Z][A-Za-z0-9]+)$/.test(taskArgs.newName)) { 70 | throw 'The contract name must be in PascalCase: https://en.wikipedia.org/wiki/Camel_case#Variations_and_synonyms'; 71 | } 72 | 73 | const oldContractFile = `${__dirname}/contracts/${CollectionConfig.contractName}.sol`; 74 | const newContractFile = `${__dirname}/contracts/${taskArgs.newName}.sol`; 75 | 76 | if (!fs.existsSync(oldContractFile)) { 77 | throw `Contract file not found: "${oldContractFile}" (did you change the configuration manually?)`; 78 | } 79 | 80 | if (fs.existsSync(newContractFile)) { 81 | throw `A file with that name already exists: "${oldContractFile}"`; 82 | } 83 | 84 | // Replace names in source files 85 | replaceInFile(__dirname + '/../minting-dapp/src/scripts/lib/NftContractType.ts', CollectionConfig.contractName, taskArgs.newName); 86 | replaceInFile(__dirname + '/config/CollectionConfig.ts', CollectionConfig.contractName, taskArgs.newName); 87 | replaceInFile(__dirname + '/lib/NftContractProvider.ts', CollectionConfig.contractName, taskArgs.newName); 88 | replaceInFile(oldContractFile, CollectionConfig.contractName, taskArgs.newName); 89 | 90 | // Rename the contract file 91 | fs.renameSync(oldContractFile, newContractFile); 92 | 93 | console.log(`Contract renamed successfully from "${CollectionConfig.contractName}" to "${taskArgs.newName}"!`); 94 | 95 | // Rebuilding types 96 | await hre.run('typechain'); 97 | }) 98 | .addPositionalParam('newName', 'The new name'); 99 | 100 | // You need to export an object to set up your config 101 | // Go to https://hardhat.org/config/ to learn more 102 | 103 | const config: HardhatUserConfig = { 104 | solidity: { 105 | version: '0.8.9', 106 | settings: { 107 | optimizer: { 108 | enabled: true, 109 | runs: 200, 110 | }, 111 | }, 112 | }, 113 | networks: { 114 | truffle: { 115 | url: 'http://localhost:24012/rpc', 116 | timeout: 60000, 117 | gasMultiplier: DEFAULT_GAS_MULTIPLIER, 118 | }, 119 | }, 120 | gasReporter: { 121 | enabled: process.env.REPORT_GAS !== undefined, 122 | currency: 'USD', 123 | coinmarketcap: process.env.GAS_REPORTER_COIN_MARKET_CAP_API_KEY, 124 | }, 125 | etherscan: { 126 | apiKey: { 127 | // Ethereum 128 | goerli: process.env.BLOCK_EXPLORER_API_KEY, 129 | mainnet: process.env.BLOCK_EXPLORER_API_KEY, 130 | rinkeby: process.env.BLOCK_EXPLORER_API_KEY, 131 | 132 | // Polygon 133 | polygon: process.env.BLOCK_EXPLORER_API_KEY, 134 | polygonMumbai: process.env.BLOCK_EXPLORER_API_KEY, 135 | }, 136 | }, 137 | }; 138 | 139 | // Setup "testnet" network 140 | if (process.env.NETWORK_TESTNET_URL !== undefined) { 141 | config.networks!.testnet = { 142 | url: process.env.NETWORK_TESTNET_URL, 143 | accounts: [process.env.NETWORK_TESTNET_PRIVATE_KEY!], 144 | gasMultiplier: DEFAULT_GAS_MULTIPLIER, 145 | }; 146 | } 147 | 148 | // Setup "mainnet" network 149 | if (process.env.NETWORK_MAINNET_URL !== undefined) { 150 | config.networks!.mainnet = { 151 | url: process.env.NETWORK_MAINNET_URL, 152 | accounts: [process.env.NETWORK_MAINNET_PRIVATE_KEY!], 153 | gasMultiplier: DEFAULT_GAS_MULTIPLIER, 154 | }; 155 | } 156 | 157 | export default config; 158 | 159 | /** 160 | * Replaces all occurrences of a string in the given file. 161 | */ 162 | function replaceInFile(file: string, search: string, replace: string): void 163 | { 164 | const fileContent = fs.readFileSync(file, 'utf8').replace(new RegExp(search, 'g'), replace); 165 | 166 | fs.writeFileSync(file, fileContent, 'utf8'); 167 | } 168 | -------------------------------------------------------------------------------- /smart-contract/lib/CollectionConfigInterface.ts: -------------------------------------------------------------------------------- 1 | import NetworkConfigInterface from '../lib/NetworkConfigInterface'; 2 | import MarketplaceConfigInterface from '../lib/MarketplaceConfigInterface'; 3 | 4 | interface SaleConfig { 5 | price: number; 6 | maxMintAmountPerTx: number; 7 | }; 8 | 9 | export default interface CollectionConfigInterface { 10 | testnet: NetworkConfigInterface; 11 | mainnet: NetworkConfigInterface; 12 | contractName: string; 13 | tokenName: string; 14 | tokenSymbol: string; 15 | hiddenMetadataUri: string; 16 | maxSupply: number; 17 | whitelistSale: SaleConfig; 18 | preSale: SaleConfig; 19 | publicSale: SaleConfig; 20 | contractAddress: string|null; 21 | marketplaceIdentifier: string; 22 | marketplaceConfig: MarketplaceConfigInterface; 23 | whitelistAddresses: string[]; 24 | }; 25 | -------------------------------------------------------------------------------- /smart-contract/lib/MarketplaceConfigInterface.ts: -------------------------------------------------------------------------------- 1 | export default interface MarketplaceConfigInterface { 2 | name: string; 3 | generateCollectionUrl: (marketplaceIdentifier: any, isMainnet: boolean) => string; 4 | }; 5 | -------------------------------------------------------------------------------- /smart-contract/lib/Marketplaces.ts: -------------------------------------------------------------------------------- 1 | import MarketplaceConfigInterface from './MarketplaceConfigInterface'; 2 | 3 | export const openSea: MarketplaceConfigInterface = { 4 | name: 'OpenSea', 5 | generateCollectionUrl: (marketplaceIdentifier: string, isMainnet: boolean) => 'https://' + (isMainnet ? 'www' : 'testnets') + '.opensea.io/collection/' + marketplaceIdentifier, 6 | } 7 | -------------------------------------------------------------------------------- /smart-contract/lib/NetworkConfigInterface.ts: -------------------------------------------------------------------------------- 1 | export default interface NetworkConfigInterface { 2 | chainId: number; 3 | symbol: string; 4 | blockExplorer: { 5 | name: string; 6 | generateContractUrl: (contractAddress: string) => string; 7 | generateTransactionUrl: (transactionAddress: string) => string; 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /smart-contract/lib/Networks.ts: -------------------------------------------------------------------------------- 1 | import NetworkConfigInterface from './NetworkConfigInterface'; 2 | 3 | /* 4 | * Local networks 5 | */ 6 | export const hardhatLocal: NetworkConfigInterface = { 7 | chainId: 31337, 8 | symbol: 'ETH (test)', 9 | blockExplorer: { 10 | name: 'Block explorer (not available for local chains)', 11 | generateContractUrl: (contractAddress: string) => `#`, 12 | generateTransactionUrl: (transactionAddress: string) => `#`, 13 | }, 14 | } 15 | 16 | /* 17 | * Ethereum 18 | */ 19 | export const ethereumTestnet: NetworkConfigInterface = { 20 | chainId: 5, 21 | symbol: 'ETH (test)', 22 | blockExplorer: { 23 | name: 'Etherscan (Goerli)', 24 | generateContractUrl: (contractAddress: string) => `https://goerli.etherscan.io/address/${contractAddress}`, 25 | generateTransactionUrl: (transactionAddress: string) => `https://goerli.etherscan.io/tx/${transactionAddress}`, 26 | }, 27 | } 28 | 29 | export const ethereumLegacyTestnet: NetworkConfigInterface = { 30 | chainId: 4, 31 | symbol: 'ETH (test)', 32 | blockExplorer: { 33 | name: 'Etherscan (Rinkeby)', 34 | generateContractUrl: (contractAddress: string) => `https://rinkeby.etherscan.io/address/${contractAddress}`, 35 | generateTransactionUrl: (transactionAddress: string) => `https://rinkeby.etherscan.io/tx/${transactionAddress}`, 36 | }, 37 | } 38 | 39 | export const ethereumMainnet: NetworkConfigInterface = { 40 | chainId: 1, 41 | symbol: 'ETH', 42 | blockExplorer: { 43 | name: 'Etherscan', 44 | generateContractUrl: (contractAddress: string) => `https://etherscan.io/address/${contractAddress}`, 45 | generateTransactionUrl: (transactionAddress: string) => `https://etherscan.io/tx/${transactionAddress}`, 46 | }, 47 | } 48 | 49 | /* 50 | * Polygon 51 | */ 52 | export const polygonTestnet: NetworkConfigInterface = { 53 | chainId: 80001, 54 | symbol: 'MATIC (test)', 55 | blockExplorer: { 56 | name: 'Polygonscan (Mumbai)', 57 | generateContractUrl: (contractAddress: string) => `https://mumbai.polygonscan.com/address/${contractAddress}`, 58 | generateTransactionUrl: (transactionAddress: string) => `https://mumbai.polygonscan.com/tx/${transactionAddress}`, 59 | }, 60 | } 61 | 62 | export const polygonMainnet: NetworkConfigInterface = { 63 | chainId: 137, 64 | symbol: 'MATIC', 65 | blockExplorer: { 66 | name: 'Polygonscan', 67 | generateContractUrl: (contractAddress: string) => `https://polygonscan.com/address/${contractAddress}`, 68 | generateTransactionUrl: (transactionAddress: string) => `https://polygonscan.com/tx/${transactionAddress}`, 69 | }, 70 | } 71 | -------------------------------------------------------------------------------- /smart-contract/lib/NftContractProvider.ts: -------------------------------------------------------------------------------- 1 | // The name below ("YourNftToken") should match the name of your Solidity contract. 2 | // It can be updated using the following command: 3 | // yarn rename-contract NEW_CONTRACT_NAME 4 | // Please DO NOT change it manually! 5 | import { YourNftToken as ContractType } from '../typechain/index'; 6 | 7 | import { ethers } from 'hardhat'; 8 | import CollectionConfig from './../config/CollectionConfig'; 9 | 10 | export default class NftContractProvider { 11 | public static async getContract(): Promise { 12 | // Check configuration 13 | if (null === CollectionConfig.contractAddress) { 14 | throw '\x1b[31merror\x1b[0m ' + 'Please add the contract address to the configuration before running this command.'; 15 | } 16 | 17 | if (await ethers.provider.getCode(CollectionConfig.contractAddress) === '0x') { 18 | throw '\x1b[31merror\x1b[0m ' + `Can't find a contract deployed to the target address: ${CollectionConfig.contractAddress}`; 19 | } 20 | 21 | return await ethers.getContractAt(CollectionConfig.contractName, CollectionConfig.contractAddress) as ContractType; 22 | } 23 | }; 24 | 25 | export type NftContractType = ContractType; 26 | -------------------------------------------------------------------------------- /smart-contract/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hashlips-lab/nft-erc721-collection-smart-contract", 3 | "version": "0.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "@nomiclabs/hardhat-ethers": "^2.0.4", 7 | "@nomiclabs/hardhat-etherscan": "^3.0.3", 8 | "@nomiclabs/hardhat-waffle": "^2.0.1", 9 | "@openzeppelin/contracts": "^4.4.2", 10 | "@typechain/ethers-v5": "^7.2.0", 11 | "@typechain/hardhat": "^2.3.1", 12 | "@types/chai": "^4.3.0", 13 | "@types/chai-as-promised": "^7.1.5", 14 | "@types/mocha": "^9.0.0", 15 | "@types/node": "^12.20.41", 16 | "@typescript-eslint/eslint-plugin": "^4.33.0", 17 | "@typescript-eslint/parser": "^4.33.0", 18 | "chai": "^4.3.4", 19 | "chai-as-promised": "^7.1.1", 20 | "dotenv": "^10.0.0", 21 | "erc721a": "^3.0.0", 22 | "eslint": "^7.32.0", 23 | "eslint-config-prettier": "^8.3.0", 24 | "eslint-config-standard": "^16.0.3", 25 | "eslint-plugin-import": "^2.25.4", 26 | "eslint-plugin-node": "^11.1.0", 27 | "eslint-plugin-prettier": "^3.4.1", 28 | "eslint-plugin-promise": "^5.2.0", 29 | "ethereum-waffle": "^3.4.0", 30 | "ethers": "^5.5.3", 31 | "hardhat": "^2.8.2", 32 | "hardhat-gas-reporter": "^1.0.7", 33 | "keccak256": "^1.0.6", 34 | "merkletreejs": "^0.2.27", 35 | "prettier": "^2.5.1", 36 | "prettier-plugin-solidity": "^1.0.0-beta.19", 37 | "solhint": "^3.3.6", 38 | "solidity-coverage": "^0.7.17", 39 | "ts-node": "^10.4.0", 40 | "typechain": "^5.2.0", 41 | "typescript": "^4.5.4" 42 | }, 43 | "scripts": { 44 | "accounts": "hardhat accounts", 45 | "rename-contract": "hardhat rename-contract", 46 | "compile": "hardhat compile --force", 47 | "test": "hardhat test", 48 | "test-extended": "EXTENDED_TESTS=1 hardhat test", 49 | "test-gas": "REPORT_GAS=1 hardhat test", 50 | "local-node": "hardhat node", 51 | "root-hash": "hardhat generate-root-hash", 52 | "proof": "hardhat generate-proof", 53 | "deploy": "hardhat run scripts/1_deploy.ts", 54 | "verify": "hardhat verify --constructor-args config/ContractArguments.ts", 55 | "whitelist-open": "hardhat run scripts/2_whitelist_open.ts", 56 | "whitelist-close": "hardhat run scripts/3_whitelist_close.ts", 57 | "presale-open": "hardhat run scripts/4_presale_open.ts", 58 | "presale-close": "hardhat run scripts/5_presale_close.ts", 59 | "public-sale-open": "hardhat run scripts/6_public_sale_open.ts", 60 | "public-sale-close": "hardhat run scripts/7_public_sale_close.ts", 61 | "reveal": "hardhat run scripts/8_reveal.ts" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /smart-contract/scripts/1_deploy.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'hardhat'; 2 | import CollectionConfig from '../config/CollectionConfig'; 3 | import { NftContractType } from '../lib/NftContractProvider'; 4 | import ContractArguments from './../config/ContractArguments'; 5 | 6 | async function main() { 7 | // Hardhat always runs the compile task when running scripts with its command 8 | // line interface. 9 | // 10 | // If this script is run directly using `node` you may want to call compile 11 | // manually to make sure everything is compiled 12 | // await hre.run('compile'); 13 | 14 | console.log('Deploying contract...'); 15 | 16 | // We get the contract to deploy 17 | const Contract = await ethers.getContractFactory(CollectionConfig.contractName); 18 | const contract = await Contract.deploy(...ContractArguments) as NftContractType; 19 | 20 | await contract.deployed(); 21 | 22 | console.log('Contract deployed to:', contract.address); 23 | } 24 | 25 | // We recommend this pattern to be able to use async/await everywhere 26 | // and properly handle errors. 27 | main().catch((error) => { 28 | console.error(error); 29 | process.exitCode = 1; 30 | }); 31 | -------------------------------------------------------------------------------- /smart-contract/scripts/2_whitelist_open.ts: -------------------------------------------------------------------------------- 1 | import { utils } from 'ethers'; 2 | import { MerkleTree } from 'merkletreejs'; 3 | import keccak256 from 'keccak256'; 4 | import CollectionConfig from './../config/CollectionConfig'; 5 | import NftContractProvider from '../lib/NftContractProvider'; 6 | 7 | async function main() { 8 | // Check configuration 9 | if (CollectionConfig.whitelistAddresses.length < 1) { 10 | throw '\x1b[31merror\x1b[0m ' + 'The whitelist is empty, please add some addresses to the configuration.'; 11 | } 12 | 13 | // Build the Merkle Tree 14 | const leafNodes = CollectionConfig.whitelistAddresses.map(addr => keccak256(addr)); 15 | const merkleTree = new MerkleTree(leafNodes, keccak256, { sortPairs: true }); 16 | const rootHash = '0x' + merkleTree.getRoot().toString('hex'); 17 | 18 | // Attach to deployed contract 19 | const contract = await NftContractProvider.getContract(); 20 | 21 | // Update sale price (if needed) 22 | const whitelistPrice = utils.parseEther(CollectionConfig.whitelistSale.price.toString()); 23 | if (!await (await contract.cost()).eq(whitelistPrice)) { 24 | console.log(`Updating the token price to ${CollectionConfig.whitelistSale.price} ${CollectionConfig.mainnet.symbol}...`); 25 | 26 | await (await contract.setCost(whitelistPrice)).wait(); 27 | } 28 | 29 | // Update max amount per TX (if needed) 30 | if (!await (await contract.maxMintAmountPerTx()).eq(CollectionConfig.whitelistSale.maxMintAmountPerTx)) { 31 | console.log(`Updating the max mint amount per TX to ${CollectionConfig.whitelistSale.maxMintAmountPerTx}...`); 32 | 33 | await (await contract.setMaxMintAmountPerTx(CollectionConfig.whitelistSale.maxMintAmountPerTx)).wait(); 34 | } 35 | 36 | // Update root hash (if changed) 37 | if ((await contract.merkleRoot()) !== rootHash) { 38 | console.log(`Updating the root hash to: ${rootHash}`); 39 | 40 | await (await contract.setMerkleRoot(rootHash)).wait(); 41 | } 42 | 43 | // Enable whitelist sale (if needed) 44 | if (!await contract.whitelistMintEnabled()) { 45 | console.log('Enabling whitelist sale...'); 46 | 47 | await (await contract.setWhitelistMintEnabled(true)).wait(); 48 | } 49 | 50 | console.log('Whitelist sale has been enabled!'); 51 | } 52 | 53 | // We recommend this pattern to be able to use async/await everywhere 54 | // and properly handle errors. 55 | main().catch((error) => { 56 | console.error(error); 57 | process.exitCode = 1; 58 | }); 59 | -------------------------------------------------------------------------------- /smart-contract/scripts/3_whitelist_close.ts: -------------------------------------------------------------------------------- 1 | import NftContractProvider from '../lib/NftContractProvider'; 2 | 3 | async function main() { 4 | // Attach to deployed contract 5 | const contract = await NftContractProvider.getContract(); 6 | 7 | // Disable whitelist sale (if needed) 8 | if (await contract.whitelistMintEnabled()) { 9 | console.log('Disabling whitelist sale...'); 10 | 11 | await (await contract.setWhitelistMintEnabled(false)).wait(); 12 | } 13 | 14 | console.log('Whitelist sale has been disabled!'); 15 | } 16 | 17 | // We recommend this pattern to be able to use async/await everywhere 18 | // and properly handle errors. 19 | main().catch((error) => { 20 | console.error(error); 21 | process.exitCode = 1; 22 | }); 23 | -------------------------------------------------------------------------------- /smart-contract/scripts/4_presale_open.ts: -------------------------------------------------------------------------------- 1 | import { utils } from 'ethers'; 2 | import CollectionConfig from './../config/CollectionConfig'; 3 | import NftContractProvider from '../lib/NftContractProvider'; 4 | 5 | async function main() { 6 | // Attach to deployed contract 7 | const contract = await NftContractProvider.getContract(); 8 | 9 | if (await contract.whitelistMintEnabled()) { 10 | throw '\x1b[31merror\x1b[0m ' + 'Please close the whitelist sale before opening a pre-sale.'; 11 | } 12 | 13 | // Update sale price (if needed) 14 | const preSalePrice = utils.parseEther(CollectionConfig.preSale.price.toString()); 15 | if (!await (await contract.cost()).eq(preSalePrice)) { 16 | console.log(`Updating the token price to ${CollectionConfig.preSale.price} ${CollectionConfig.mainnet.symbol}...`); 17 | 18 | await (await contract.setCost(preSalePrice)).wait(); 19 | } 20 | 21 | // Update max amount per TX (if needed) 22 | if (!await (await contract.maxMintAmountPerTx()).eq(CollectionConfig.preSale.maxMintAmountPerTx)) { 23 | console.log(`Updating the max mint amount per TX to ${CollectionConfig.preSale.maxMintAmountPerTx}...`); 24 | 25 | await (await contract.setMaxMintAmountPerTx(CollectionConfig.preSale.maxMintAmountPerTx)).wait(); 26 | } 27 | 28 | // Unpause the contract (if needed) 29 | if (await contract.paused()) { 30 | console.log('Unpausing the contract...'); 31 | 32 | await (await contract.setPaused(false)).wait(); 33 | } 34 | 35 | console.log('Pre-sale is now open!'); 36 | } 37 | 38 | // We recommend this pattern to be able to use async/await everywhere 39 | // and properly handle errors. 40 | main().catch((error) => { 41 | console.error(error); 42 | process.exitCode = 1; 43 | }); 44 | -------------------------------------------------------------------------------- /smart-contract/scripts/5_presale_close.ts: -------------------------------------------------------------------------------- 1 | import NftContractProvider from '../lib/NftContractProvider'; 2 | 3 | async function main() { 4 | // Attach to deployed contract 5 | const contract = await NftContractProvider.getContract(); 6 | 7 | // Pause the contract (if needed) 8 | if (!await contract.paused()) { 9 | console.log('Pausing the contract...'); 10 | 11 | await (await contract.setPaused(true)).wait(); 12 | } 13 | 14 | console.log('Pre-sale is now closed!'); 15 | } 16 | 17 | // We recommend this pattern to be able to use async/await everywhere 18 | // and properly handle errors. 19 | main().catch((error) => { 20 | console.error(error); 21 | process.exitCode = 1; 22 | }); 23 | -------------------------------------------------------------------------------- /smart-contract/scripts/6_public_sale_open.ts: -------------------------------------------------------------------------------- 1 | import { utils } from 'ethers'; 2 | import CollectionConfig from './../config/CollectionConfig'; 3 | import NftContractProvider from '../lib/NftContractProvider'; 4 | 5 | async function main() { 6 | // Attach to deployed contract 7 | const contract = await NftContractProvider.getContract(); 8 | 9 | if (await contract.whitelistMintEnabled()) { 10 | throw '\x1b[31merror\x1b[0m ' + 'Please close the whitelist sale before opening a public sale.'; 11 | } 12 | 13 | // Update sale price (if needed) 14 | const publicSalePrice = utils.parseEther(CollectionConfig.publicSale.price.toString()); 15 | if (!await (await contract.cost()).eq(publicSalePrice)) { 16 | console.log(`Updating the token price to ${CollectionConfig.publicSale.price} ${CollectionConfig.mainnet.symbol}...`); 17 | 18 | await (await contract.setCost(publicSalePrice)).wait(); 19 | } 20 | 21 | // Update max amount per TX (if needed) 22 | if (!await (await contract.maxMintAmountPerTx()).eq(CollectionConfig.publicSale.maxMintAmountPerTx)) { 23 | console.log(`Updating the max mint amount per TX to ${CollectionConfig.publicSale.maxMintAmountPerTx}...`); 24 | 25 | await (await contract.setMaxMintAmountPerTx(CollectionConfig.publicSale.maxMintAmountPerTx)).wait(); 26 | } 27 | 28 | // Unpause the contract (if needed) 29 | if (await contract.paused()) { 30 | console.log('Unpausing the contract...'); 31 | 32 | await (await contract.setPaused(false)).wait(); 33 | } 34 | 35 | console.log('Public sale is now open!'); 36 | } 37 | 38 | // We recommend this pattern to be able to use async/await everywhere 39 | // and properly handle errors. 40 | main().catch((error) => { 41 | console.error(error); 42 | process.exitCode = 1; 43 | }); 44 | -------------------------------------------------------------------------------- /smart-contract/scripts/7_public_sale_close.ts: -------------------------------------------------------------------------------- 1 | import NftContractProvider from '../lib/NftContractProvider'; 2 | 3 | async function main() { 4 | // Attach to deployed contract 5 | const contract = await NftContractProvider.getContract(); 6 | 7 | // Pause the contract (if needed) 8 | if (!await contract.paused()) { 9 | console.log('Pausing the contract...'); 10 | 11 | await (await contract.setPaused(true)).wait(); 12 | } 13 | 14 | console.log('Public sale is now closed!'); 15 | } 16 | 17 | // We recommend this pattern to be able to use async/await everywhere 18 | // and properly handle errors. 19 | main().catch((error) => { 20 | console.error(error); 21 | process.exitCode = 1; 22 | }); 23 | -------------------------------------------------------------------------------- /smart-contract/scripts/8_reveal.ts: -------------------------------------------------------------------------------- 1 | import NftContractProvider from '../lib/NftContractProvider'; 2 | 3 | async function main() { 4 | if (undefined === process.env.COLLECTION_URI_PREFIX || process.env.COLLECTION_URI_PREFIX === 'ipfs://__CID___/') { 5 | throw '\x1b[31merror\x1b[0m ' + 'Please add the URI prefix to the ENV configuration before running this command.'; 6 | } 7 | 8 | // Attach to deployed contract 9 | const contract = await NftContractProvider.getContract(); 10 | 11 | // Update URI prefix (if changed) 12 | if ((await contract.uriPrefix()) !== process.env.COLLECTION_URI_PREFIX) { 13 | console.log(`Updating the URI prefix to: ${process.env.COLLECTION_URI_PREFIX}`); 14 | 15 | await (await contract.setUriPrefix(process.env.COLLECTION_URI_PREFIX)).wait(); 16 | } 17 | 18 | // Revealing the collection (if needed) 19 | if (!await contract.revealed()) { 20 | console.log('Revealing the collection...'); 21 | 22 | await (await contract.setRevealed(true)).wait(); 23 | } 24 | 25 | console.log('Your collection is now revealed!'); 26 | } 27 | 28 | // We recommend this pattern to be able to use async/await everywhere 29 | // and properly handle errors. 30 | main().catch((error) => { 31 | console.error(error); 32 | process.exitCode = 1; 33 | }); 34 | -------------------------------------------------------------------------------- /smart-contract/test/index.ts: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import ChaiAsPromised from 'chai-as-promised'; 3 | import { BigNumber, utils } from 'ethers'; 4 | import { ethers } from 'hardhat'; 5 | import { MerkleTree } from 'merkletreejs'; 6 | import keccak256 from 'keccak256'; 7 | import CollectionConfig from './../config/CollectionConfig'; 8 | import ContractArguments from '../config/ContractArguments'; 9 | import { NftContractType } from '../lib/NftContractProvider'; 10 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 11 | 12 | chai.use(ChaiAsPromised); 13 | 14 | enum SaleType { 15 | WHITELIST = CollectionConfig.whitelistSale.price, 16 | PRE_SALE = CollectionConfig.preSale.price, 17 | PUBLIC_SALE = CollectionConfig.publicSale.price, 18 | }; 19 | 20 | const whitelistAddresses = [ 21 | // Hardhat test addresses... 22 | "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", 23 | "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", 24 | "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", 25 | "0x976EA74026E726554dB657fA54763abd0C3a0aa9", 26 | "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955", 27 | "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f", 28 | "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", 29 | "0xBcd4042DE499D14e55001CcbB24a551F3b954096", 30 | "0x71bE63f3384f5fb98995898A86B02Fb2426c5788", 31 | "0xFABB0ac9d68B0B445fB7357272Ff202C5651694a", 32 | "0x1CBd3b2770909D4e10f157cABC84C7264073C9Ec", 33 | "0xdF3e18d64BC6A983f673Ab319CCaE4f1a57C7097", 34 | "0xcd3B766CCDd6AE721141F452C550Ca635964ce71", 35 | "0x2546BcD3c84621e976D8185a91A922aE77ECEc30", 36 | "0xbDA5747bFD65F08deb54cb465eB87D40e51B197E", 37 | "0xdD2FD4581271e230360230F9337D5c0430Bf44C0", 38 | "0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199" 39 | ]; 40 | 41 | function getPrice(saleType: SaleType, mintAmount: number) { 42 | return utils.parseEther(saleType.toString()).mul(mintAmount); 43 | } 44 | 45 | describe(CollectionConfig.contractName, function () { 46 | let owner!: SignerWithAddress; 47 | let whitelistedUser!: SignerWithAddress; 48 | let holder!: SignerWithAddress; 49 | let externalUser!: SignerWithAddress; 50 | let contract!: NftContractType; 51 | 52 | before(async function () { 53 | [owner, whitelistedUser, holder, externalUser] = await ethers.getSigners(); 54 | }); 55 | 56 | it('Contract deployment', async function () { 57 | const Contract = await ethers.getContractFactory(CollectionConfig.contractName); 58 | contract = await Contract.deploy(...ContractArguments) as NftContractType; 59 | 60 | await contract.deployed(); 61 | }); 62 | 63 | it('Check initial data', async function () { 64 | expect(await contract.name()).to.equal(CollectionConfig.tokenName); 65 | expect(await contract.symbol()).to.equal(CollectionConfig.tokenSymbol); 66 | expect(await contract.cost()).to.equal(getPrice(SaleType.WHITELIST, 1)); 67 | expect(await contract.maxSupply()).to.equal(CollectionConfig.maxSupply); 68 | expect(await contract.maxMintAmountPerTx()).to.equal(CollectionConfig.whitelistSale.maxMintAmountPerTx); 69 | expect(await contract.hiddenMetadataUri()).to.equal(CollectionConfig.hiddenMetadataUri); 70 | 71 | expect(await contract.paused()).to.equal(true); 72 | expect(await contract.whitelistMintEnabled()).to.equal(false); 73 | expect(await contract.revealed()).to.equal(false); 74 | 75 | await expect(contract.tokenURI(1)).to.be.revertedWith('ERC721Metadata: URI query for nonexistent token'); 76 | }); 77 | 78 | it('Before any sale', async function () { 79 | // Nobody should be able to mint from a paused contract 80 | await expect(contract.connect(whitelistedUser).mint(1, {value: getPrice(SaleType.WHITELIST, 1)})).to.be.revertedWith('The contract is paused!'); 81 | await expect(contract.connect(whitelistedUser).whitelistMint(1, [], {value: getPrice(SaleType.WHITELIST, 1)})).to.be.revertedWith('The whitelist sale is not enabled!'); 82 | await expect(contract.connect(holder).mint(1, {value: getPrice(SaleType.WHITELIST, 1)})).to.be.revertedWith('The contract is paused!'); 83 | await expect(contract.connect(holder).whitelistMint(1, [], {value: getPrice(SaleType.WHITELIST, 1)})).to.be.revertedWith('The whitelist sale is not enabled!'); 84 | await expect(contract.connect(owner).mint(1, {value: getPrice(SaleType.WHITELIST, 1)})).to.be.revertedWith('The contract is paused!'); 85 | await expect(contract.connect(owner).whitelistMint(1, [], {value: getPrice(SaleType.WHITELIST, 1)})).to.be.revertedWith('The whitelist sale is not enabled!'); 86 | 87 | // The owner should always be able to run mintForAddress 88 | await (await contract.mintForAddress(1, await owner.getAddress())).wait(); 89 | await (await contract.mintForAddress(1, await whitelistedUser.getAddress())).wait(); 90 | // But not over the maxMintAmountPerTx 91 | await expect(contract.mintForAddress( 92 | await (await contract.maxMintAmountPerTx()).add(1), 93 | await holder.getAddress(), 94 | )).to.be.revertedWith('Invalid mint amount!'); 95 | 96 | // Check balances 97 | expect(await contract.balanceOf(await owner.getAddress())).to.equal(1); 98 | expect(await contract.balanceOf(await whitelistedUser.getAddress())).to.equal(1); 99 | expect(await contract.balanceOf(await holder.getAddress())).to.equal(0); 100 | expect(await contract.balanceOf(await externalUser.getAddress())).to.equal(0); 101 | }); 102 | 103 | it('Whitelist sale', async function () { 104 | // Build MerkleTree 105 | const leafNodes = whitelistAddresses.map(addr => keccak256(addr)); 106 | const merkleTree = new MerkleTree(leafNodes, keccak256, { sortPairs: true }); 107 | const rootHash = merkleTree.getRoot(); 108 | // Update the root hash 109 | await (await contract.setMerkleRoot('0x' + rootHash.toString('hex'))).wait(); 110 | 111 | await contract.setWhitelistMintEnabled(true); 112 | 113 | await contract.connect(whitelistedUser).whitelistMint( 114 | 1, 115 | merkleTree.getHexProof(keccak256(await whitelistedUser.getAddress())), 116 | {value: getPrice(SaleType.WHITELIST, 1)}, 117 | ); 118 | // Trying to mint twice 119 | await expect(contract.connect(whitelistedUser).whitelistMint( 120 | 1, 121 | merkleTree.getHexProof(keccak256(await whitelistedUser.getAddress())), 122 | {value: getPrice(SaleType.WHITELIST, 1)}, 123 | )).to.be.revertedWith('Address already claimed!'); 124 | // Sending an invalid mint amount 125 | await expect(contract.connect(whitelistedUser).whitelistMint( 126 | await (await contract.maxMintAmountPerTx()).add(1), 127 | merkleTree.getHexProof(keccak256(await whitelistedUser.getAddress())), 128 | {value: getPrice(SaleType.WHITELIST, await (await contract.maxMintAmountPerTx()).add(1).toNumber())}, 129 | )).to.be.revertedWith('Invalid mint amount!'); 130 | // Sending insufficient funds 131 | await expect(contract.connect(whitelistedUser).whitelistMint( 132 | 1, 133 | merkleTree.getHexProof(keccak256(await whitelistedUser.getAddress())), 134 | {value: getPrice(SaleType.WHITELIST, 1).sub(1)}, 135 | )).to.be.rejectedWith(Error, 'insufficient funds for intrinsic transaction cost'); 136 | // Pretending to be someone else 137 | await expect(contract.connect(holder).whitelistMint( 138 | 1, 139 | merkleTree.getHexProof(keccak256(await whitelistedUser.getAddress())), 140 | {value: getPrice(SaleType.WHITELIST, 1)}, 141 | )).to.be.revertedWith('Invalid proof!'); 142 | // Sending an invalid proof 143 | await expect(contract.connect(holder).whitelistMint( 144 | 1, 145 | merkleTree.getHexProof(keccak256(await holder.getAddress())), 146 | {value: getPrice(SaleType.WHITELIST, 1)}, 147 | )).to.be.revertedWith('Invalid proof!'); 148 | // Sending no proof at all 149 | await expect(contract.connect(holder).whitelistMint( 150 | 1, 151 | [], 152 | {value: getPrice(SaleType.WHITELIST, 1)}, 153 | )).to.be.revertedWith('Invalid proof!'); 154 | 155 | // Pause whitelist sale 156 | await contract.setWhitelistMintEnabled(false); 157 | await contract.setCost(utils.parseEther(CollectionConfig.preSale.price.toString())); 158 | 159 | // Check balances 160 | expect(await contract.balanceOf(await owner.getAddress())).to.equal(1); 161 | expect(await contract.balanceOf(await whitelistedUser.getAddress())).to.equal(2); 162 | expect(await contract.balanceOf(await holder.getAddress())).to.equal(0); 163 | expect(await contract.balanceOf(await externalUser.getAddress())).to.equal(0); 164 | }); 165 | 166 | it('Pre-sale (same as public sale)', async function () { 167 | await contract.setMaxMintAmountPerTx(CollectionConfig.preSale.maxMintAmountPerTx); 168 | await contract.setPaused(false); 169 | await contract.connect(holder).mint(2, {value: getPrice(SaleType.PRE_SALE, 2)}); 170 | await contract.connect(whitelistedUser).mint(1, {value: getPrice(SaleType.PRE_SALE, 1)}); 171 | // Sending insufficient funds 172 | await expect(contract.connect(holder).mint(1, {value: getPrice(SaleType.PRE_SALE, 1).sub(1)})).to.be.rejectedWith(Error, 'insufficient funds for intrinsic transaction cost'); 173 | // Sending an invalid mint amount 174 | await expect(contract.connect(whitelistedUser).mint( 175 | await (await contract.maxMintAmountPerTx()).add(1), 176 | {value: getPrice(SaleType.PRE_SALE, await (await contract.maxMintAmountPerTx()).add(1).toNumber())}, 177 | )).to.be.revertedWith('Invalid mint amount!'); 178 | // Sending a whitelist mint transaction 179 | await expect(contract.connect(whitelistedUser).whitelistMint( 180 | 1, 181 | [], 182 | {value: getPrice(SaleType.WHITELIST, 1)}, 183 | )).to.be.rejectedWith(Error, 'insufficient funds for intrinsic transaction cost'); 184 | 185 | // Pause pre-sale 186 | await contract.setPaused(true); 187 | await contract.setCost(utils.parseEther(CollectionConfig.publicSale.price.toString())); 188 | }); 189 | 190 | it('Owner only functions', async function () { 191 | await expect(contract.connect(externalUser).mintForAddress(1, await externalUser.getAddress())).to.be.revertedWith('Ownable: caller is not the owner'); 192 | await expect(contract.connect(externalUser).setRevealed(false)).to.be.revertedWith('Ownable: caller is not the owner'); 193 | await expect(contract.connect(externalUser).setCost(utils.parseEther('0.0000001'))).to.be.revertedWith('Ownable: caller is not the owner'); 194 | await expect(contract.connect(externalUser).setMaxMintAmountPerTx(99999)).to.be.revertedWith('Ownable: caller is not the owner'); 195 | await expect(contract.connect(externalUser).setHiddenMetadataUri('INVALID_URI')).to.be.revertedWith('Ownable: caller is not the owner'); 196 | await expect(contract.connect(externalUser).setUriPrefix('INVALID_PREFIX')).to.be.revertedWith('Ownable: caller is not the owner'); 197 | await expect(contract.connect(externalUser).setUriSuffix('INVALID_SUFFIX')).to.be.revertedWith('Ownable: caller is not the owner'); 198 | await expect(contract.connect(externalUser).setPaused(false)).to.be.revertedWith('Ownable: caller is not the owner'); 199 | await expect(contract.connect(externalUser).setMerkleRoot('0x0000000000000000000000000000000000000000000000000000000000000000')).to.be.revertedWith('Ownable: caller is not the owner'); 200 | await expect(contract.connect(externalUser).setWhitelistMintEnabled(false)).to.be.revertedWith('Ownable: caller is not the owner'); 201 | await expect(contract.connect(externalUser).withdraw()).to.be.revertedWith('Ownable: caller is not the owner'); 202 | }); 203 | 204 | it('Wallet of owner', async function () { 205 | expect(await contract.tokensOfOwner(await owner.getAddress())).deep.equal([ 206 | BigNumber.from(1), 207 | ]); 208 | expect(await contract.tokensOfOwner(await whitelistedUser.getAddress())).deep.equal([ 209 | BigNumber.from(2), 210 | BigNumber.from(3), 211 | BigNumber.from(6), 212 | ]); 213 | expect(await contract.tokensOfOwner(await holder.getAddress())).deep.equal([ 214 | BigNumber.from(4), 215 | BigNumber.from(5), 216 | ]); 217 | expect(await contract.tokensOfOwner(await externalUser.getAddress())).deep.equal([]); 218 | }); 219 | 220 | it('Supply checks (long)', async function () { 221 | if (process.env.EXTENDED_TESTS === undefined) { 222 | this.skip(); 223 | } 224 | 225 | const alreadyMinted = 6; 226 | const maxMintAmountPerTx = 1000; 227 | const iterations = Math.floor((CollectionConfig.maxSupply - alreadyMinted) / maxMintAmountPerTx); 228 | const expectedTotalSupply = iterations * maxMintAmountPerTx + alreadyMinted; 229 | const lastMintAmount = CollectionConfig.maxSupply - expectedTotalSupply; 230 | expect(await contract.totalSupply()).to.equal(alreadyMinted); 231 | 232 | await contract.setPaused(false); 233 | await contract.setMaxMintAmountPerTx(maxMintAmountPerTx); 234 | 235 | await Promise.all([...Array(iterations).keys()].map(async () => await contract.connect(whitelistedUser).mint(maxMintAmountPerTx, {value: getPrice(SaleType.PUBLIC_SALE, maxMintAmountPerTx)}))); 236 | 237 | // Try to mint over max supply (before sold-out) 238 | await expect(contract.connect(holder).mint(lastMintAmount + 1, {value: getPrice(SaleType.PUBLIC_SALE, lastMintAmount + 1)})).to.be.revertedWith('Max supply exceeded!'); 239 | await expect(contract.connect(holder).mint(lastMintAmount + 2, {value: getPrice(SaleType.PUBLIC_SALE, lastMintAmount + 2)})).to.be.revertedWith('Max supply exceeded!'); 240 | 241 | expect(await contract.totalSupply()).to.equal(expectedTotalSupply); 242 | 243 | // Mint last tokens with owner address and test walletOfOwner(...) 244 | await contract.connect(owner).mint(lastMintAmount, {value: getPrice(SaleType.PUBLIC_SALE, lastMintAmount)}); 245 | const expectedWalletOfOwner = [ 246 | BigNumber.from(1), 247 | ]; 248 | for (const i of [...Array(lastMintAmount).keys()].reverse()) { 249 | expectedWalletOfOwner.push(BigNumber.from(CollectionConfig.maxSupply - i)); 250 | } 251 | expect(await contract.tokensOfOwner( 252 | await owner.getAddress(), 253 | { 254 | // Set gas limit to the maximum value since this function should be used off-chain only and it would fail otherwise... 255 | gasLimit: BigNumber.from('0xffffffffffffffff'), 256 | }, 257 | )).deep.equal(expectedWalletOfOwner); 258 | 259 | // Try to mint over max supply (after sold-out) 260 | await expect(contract.connect(whitelistedUser).mint(1, {value: getPrice(SaleType.PUBLIC_SALE, 1)})).to.be.revertedWith('Max supply exceeded!'); 261 | 262 | expect(await contract.totalSupply()).to.equal(CollectionConfig.maxSupply); 263 | }); 264 | 265 | it('Token URI generation', async function () { 266 | const uriPrefix = 'ipfs://__COLLECTION_CID__/'; 267 | const uriSuffix = '.json'; 268 | const totalSupply = await contract.totalSupply(); 269 | 270 | expect(await contract.tokenURI(1)).to.equal(CollectionConfig.hiddenMetadataUri); 271 | 272 | // Reveal collection 273 | await contract.setUriPrefix(uriPrefix); 274 | await contract.setRevealed(true); 275 | 276 | // ERC721A uses token IDs starting from 0 internally... 277 | await expect(contract.tokenURI(0)).to.be.revertedWith('ERC721Metadata: URI query for nonexistent token'); 278 | 279 | // Testing first and last minted tokens 280 | expect(await contract.tokenURI(1)).to.equal(`${uriPrefix}1${uriSuffix}`); 281 | expect(await contract.tokenURI(totalSupply)).to.equal(`${uriPrefix}${totalSupply}${uriSuffix}`); 282 | }); 283 | }); 284 | -------------------------------------------------------------------------------- /smart-contract/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "outDir": "dist", 8 | "declaration": true, 9 | "resolveJsonModule": true 10 | }, 11 | "include": ["./scripts", "./test", "./typechain"], 12 | "files": ["./hardhat.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | --------------------------------------------------------------------------------