diff --git a/.babelrc b/.babelrc new file mode 100755 index 0000000..1320b9a --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@babel/preset-env"] +} diff --git a/.env.sample b/.env.sample new file mode 100755 index 0000000..3b31433 --- /dev/null +++ b/.env.sample @@ -0,0 +1,5 @@ +INFURA_API_KEY="infura_api_key" +MNEMONIC="twelve words mnemonic" +ETHERSCAN_API_KEY="etherscan_api_key" +PRIVATE_KEY="privete_key_starting_with_0x" +REPORT_GAS=true diff --git a/.eslintignore b/.eslintignore new file mode 100755 index 0000000..d1888ea --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +node_modules +coverage +artifacts +build +docs diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100755 index 0000000..dd2cfd4 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "env": { + "browser": true, + "es6": true + }, + "extends": ["airbnb-base"], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module" + }, + "rules": { + "indent": ["error", 2], + "no-undef": "off", + "prefer-const": "off", + "no-console": "off", + "linebreak-style": "off", + "operator-linebreak": "off", + "object-curly-newline": "off", + "arrow-body-style": "off" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100755 index 0000000..52031de --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sol linguist-language=Solidity diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100755 index 0000000..02868ca --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,23 @@ +name: build +on: push + +jobs: + coverage: + name: build + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + - name: setup-node + uses: actions/setup-node@v1 + with: + node-version: "10.x" + - name: install + run: npm ci + - name: build + run: npm run compile + - name: upload artifacts + uses: actions/upload-artifact@v1 + with: + name: artifacts + path: artifacts/contracts diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100755 index 0000000..11b79fd --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,26 @@ +name: coverage +on: push + +jobs: + coverage: + name: coverage + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + - name: setup-node + uses: actions/setup-node@v1 + with: + node-version: "10.x" + - name: install + run: npm ci + - name: test+coverage + run: npm run coverage + env: + INFURA_API_KEY: ${{ secrets.INFURA_API_KEY }} + - name: upload artifacts + uses: actions/upload-artifact@v1 + if: always() + with: + name: coverage + path: coverage diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100755 index 0000000..d9fe9d4 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,20 @@ +name: lint +on: push + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + - name: setup-node + uses: actions/setup-node@v1 + with: + node-version: "10.x" + - name: install + run: npm ci + - name: build + run: npm run compile + - name: lint + run: npm run lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100755 index 0000000..7b59356 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: test +on: push + +jobs: + coverage: + name: test + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + - name: setup-node + uses: actions/setup-node@v1 + with: + node-version: "10.x" + - name: install + run: npm ci + - name: test + run: npm run test + env: + INFURA_API_KEY: ${{ secrets.INFURA_API_KEY }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb868d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +.env +cache +artifacts +.DS_Store +coverage +coverage.json diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..64e09ab --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx --no lint-staged \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100755 index 0000000..db02a5d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules +coverage +artifacts +build +cache diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..7d95f61 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,28 @@ +{ + "overrides": [ + { + "files": "*.sol", + "options": { + "printWidth": 99, + "tabWidth": 4, + "useTabs": false, + "singleQuote": false, + "bracketSpacing": true, + "explicitTypes": "always", + "trailingComma": "none" + } + }, + { + "files": "*.js", + "options": { + "printWidth": 99, + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "bracketSpacing": true, + "explicitTypes": "always", + "trailingComma": "all" + } + } + ] +} diff --git a/.solcover.js b/.solcover.js new file mode 100755 index 0000000..9d2f23f --- /dev/null +++ b/.solcover.js @@ -0,0 +1,54 @@ +require('dotenv').config(); + +module.exports = { + skipFiles: [], + providerOptions: { + accounts: [ + { + secretKey: `${process.env.PRIVATE_KEY}`, + balance: '0x56BC75E2D63100000', + }, + { + secretKey: '0xbe5d6e330de6c44c137f8fb45fa44dada079fb8bc29d290cadd8f882035dd189', + balance: '0x56BC75E2D63100000', + }, + { + secretKey: '0x473acc210edb35998de9dc65495bafbf0a3804950482cd2b48af7bba7046d7de', + balance: '0x56BC75E2D63100000', + }, + { + secretKey: '0x3893470a6bcee2e2652eea6dddd6c677925453529313bddd86ce61fd29e06313', + balance: '0x56BC75E2D63100000', + }, + { + secretKey: '0x8bcaeea38f9d2ecb9719dc31b7dd8ef4d3a7c27fed7d2a5e29c15677f1d70a2d', + balance: '0x56BC75E2D63100000', + }, + { + secretKey: '0x159496cd9b1532e326dbb1759fb57dfca6722568690713032bab3b7a0aaf0fbd', + balance: '0x56BC75E2D63100000', + }, + { + secretKey: '0x3ce40f93372672923bda9fc1e8581428d02510c09418d9ebb8182b232234446d', + balance: '0x56BC75E2D63100000', + }, + { + secretKey: '0x92070709cd34955ec8aaf14b1b4cd8197ee4743189391072629538e52ef18014', + balance: '0x56BC75E2D63100000', + }, + { + secretKey: '0x0af73f5c72996143627524d0b22134e66d47e594b9598a94ef94ab1b781e7460', + balance: '0x56BC75E2D63100000', + }, + { + secretKey: '0x0272de3730704f4150e3c691cb2538ff22146affb578a845399a5def59f24e17', + balance: '0x56BC75E2D63100000', + }, + ], + fork: `https://mainnet.infura.io/v3/${process.env.INFURA_API_KEY}`, + gasLimit: 0x1fffffffffffff, + }, + mocha: { + enableTimeouts: false, + }, +}; diff --git a/.solhint.json b/.solhint.json new file mode 100755 index 0000000..919c74d --- /dev/null +++ b/.solhint.json @@ -0,0 +1,19 @@ +{ + "extends": "solhint:all", + "plugins": ["prettier"], + "rules": { + "mark-callable-contracts": ["off"], + "reason-string": ["error", { "maxLength": 50 }], + "function-max-lines": ["error", 99], + "compiler-version": ["off"], + "private-vars-leading-underscore": ["off"], + "func-visibility": [ + "warn", + { + "ignoreConstructors": true + } + ], + "prettier/prettier": "warn", + "comprehensive-interface": ["off"] + } +} diff --git a/.solhintignore b/.solhintignore new file mode 100755 index 0000000..3c3629e --- /dev/null +++ b/.solhintignore @@ -0,0 +1 @@ +node_modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..029dd25 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Igor Sobolev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..74b2cdb --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# zerion-genesis-nft + +This is a project with a simple NFT token. + +The main functionality of this token is `claim()` function. +It can be called once per EOA (Ethereum address) and only within a deadline (5 days after the deployment). +For every account, `call()` function 'randomly' mints 1 of 10 NFT tokens with the following probabilities: + +0. Legendary – 0.1% +1. Rare – 5.1% +2. Rare – 5.1% +3. Rare – 5.1% +4. Common – 14.1% +5. Common – 14.1% +6. Common – 14.1% +7. Common – 14.1% +8. Common – 14.1% +9. Common – 14.1% + +The mainnet address is the following: [0x0000000000000000000000000000000000000000](https://etherscan.io/address/0x0000000000000000000000000000000000000000#code). + +Call `claim()` function within a deadline and try your luck! diff --git a/contracts/IZerionGenesisNFT.sol b/contracts/IZerionGenesisNFT.sol new file mode 100644 index 0000000..137b58b --- /dev/null +++ b/contracts/IZerionGenesisNFT.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.6; + +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; + +interface IZerionGenesisNFT is IERC1155 { + /// @notice Claims a random Zerion NFT for the `msg.sender`. + /// @dev Can be called only by an EOA. + /// @dev Can be called once per account. + /// @dev Can be called only prior to the deadline. + function claim() external; + + /// @notice Shows the latest time Zerion NFTs can be claimed. + /// @return Timestamp of minting deadline. + function deadline() external view returns (uint256); + + /// @notice Shows the rarities for Zerion NFTs. + /// @return Rarity for a given id, multiplied by 1000. + function rarity(uint256 tokenId) external view returns (uint256); + + /// @notice Indicates whether the account has already claimed Zerion NFT. + function claimed(address account) external view returns (bool); + + /// @notice Collection name. + function name() external view returns (string memory); + + /// @notice Collection symbol. + function symbol() external view returns (string memory); + + /// @notice Collection metadata URI. + function contractURI() external view returns (string memory); +} diff --git a/contracts/ZerionGenesisNFT.sol b/contracts/ZerionGenesisNFT.sol new file mode 100644 index 0000000..ac57177 --- /dev/null +++ b/contracts/ZerionGenesisNFT.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.6; + +import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol"; +import "./IZerionGenesisNFT.sol"; + +contract ZerionGenesisNFT is ERC1155Supply, IZerionGenesisNFT { + /// @inheritdoc IZerionGenesisNFT + mapping(address => bool) public override claimed; + /// @inheritdoc IZerionGenesisNFT + uint256 public immutable override deadline; + /// @inheritdoc IZerionGenesisNFT + string public override name; + /// @inheritdoc IZerionGenesisNFT + string public override symbol; + /// @inheritdoc IZerionGenesisNFT + string public override contractURI; + /// @notice IPFS URI for a given id. + mapping(uint256 => string) public override uri; + + bytes10 internal immutable rarities; + uint256 internal immutable totalRarity; + + uint256 internal constant TOKEN_AMOUNT = 1; + string internal constant IPFS_PREFIX = "ipfs://"; + bytes4 private constant INTERFACE_ID_CONTRACT_URI = 0xe8a3d485; + + error AlreadyClaimed(address account); + error ExceedsDeadline(uint256 timestamp, uint256 deadline); + error OnlyTxOrigin(); + + /// @notice Creates Zerion NFTs, stores all the required parameters. + /// @param ipfsHashes_ IPFS hashes for `tokenId` from 0 to 9. + /// @param contractIpfsHash_ IPFS hash for the collection metadata. + /// @param rarities_ Rarities for `tokenId` from 0 to 9. + /// @param name_ Collection name. + /// @param symbol_ Collection symbol. + /// @param deadline_ Deadline the tokens cannot be claimed after. + constructor( + string[10] memory ipfsHashes_, + string memory contractIpfsHash_, + bytes10 rarities_, + string memory name_, + string memory symbol_, + uint256 deadline_ + ) ERC1155("") { + for (uint256 i = 0; i < 10; i++) { + uri[i] = hashToURI(ipfsHashes_[i]); + emit URI(uri[i], i); + } + contractURI = hashToURI(contractIpfsHash_); + + rarities = rarities_; + uint256 temp = 0; + for (uint256 i = 0; i < 10; i++) { + temp += uint256(uint8(rarities_[i])); + } + totalRarity = temp; + + name = name_; + symbol = symbol_; + deadline = deadline_; + } + + /// @inheritdoc IZerionGenesisNFT + function claim() external override { + address msgSender = _msgSender(); + checkRequirements(msgSender); + + // solhint-disable-next-line not-rely-on-time + uint256 tokenId = getId(block.timestamp); + _mint(msgSender, tokenId, TOKEN_AMOUNT, new bytes(0)); + + claimed[msgSender] = true; + } + + /// @inheritdoc IZerionGenesisNFT + function rarity(uint256 tokenId) external view override returns (uint256) { + if (tokenId > 9) return uint256(0); + + return (uint256(uint8(rarities[tokenId])) * 1000) / uint256(totalRarity); + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC1155, IERC165) + returns (bool) + { + return interfaceId == INTERFACE_ID_CONTRACT_URI || super.supportsInterface(interfaceId); + } + + /// @dev Reverts if the `account` has already claimed an NFT or is not an EOA. + /// @dev Also reverts if the current timestamp exceeds the deadline. + function checkRequirements(address account) internal view { + // solhint-disable-next-line avoid-tx-origin + if (tx.origin != account) revert OnlyTxOrigin(); + if (claimed[account]) revert AlreadyClaimed(account); + // solhint-disable-next-line not-rely-on-time + if (block.timestamp > deadline) revert ExceedsDeadline(block.timestamp, deadline); + } + + /// @dev Randomly (based on caller address and block timestamp) gets a token id. + /// @param salt Used to call this function multiple times (initially, `block.timestamp`). + function getId(uint256 salt) internal view returns (uint256) { + // We do not need a true random here as it is not worth manipulating a timestamp. + // slither-disable-next-line weak-prng + uint256 number = uint256(keccak256(abi.encodePacked(_msgSender(), salt))) % totalRarity; + + uint256 limit = totalRarity; + for (uint256 i = 9; i > 0; i--) { + limit -= uint256(uint8(rarities[i])); + // slither-disable-next-line timestamp + if (number >= limit) return i; + } + + // We limit the amount of NFTs with `id == 0` by 10. + if (totalSupply(0) == 10) return getId(salt + 1); + return uint256(0); + } + + /// @dev Adds IPFS prefix for a given IPFS hash. + function hashToURI(string memory ipfsHash) internal pure returns (string memory) { + return string(abi.encodePacked(IPFS_PREFIX, ipfsHash)); + } +} diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..d36edc0 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,13 @@ + + +
+ +